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::envelope::{CompletionFences, CompletionToken, idempotency_key};
82use super::outbox_dispatcher::{DeliveryGate, OutboxRowDispatch};
83use super::registry::{ConnectedWorkerRegistry, WorkerDelivery, WorkerHandle, WorkerRegistration};
84use crate::error::ServerError;
85
86/// Upper bound on how long a server-initiated intervention push waits for the
87/// worker's correlated control-plane acknowledgement. Activity dispatch replies
88/// use an unbounded held wait because an activity may legitimately run a while.
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 execution-generation proof echoed verbatim by the worker.
119 pub completion_token: String,
120 /// Stable external-effect key for this run and action site.
121 pub idempotency_key: String,
122 /// Opaque activity input bytes (JSON-tagged on the aion side).
123 pub input: Vec<u8>,
124 /// One-based delivery attempt, mirroring the gRPC `ActivityTask.attempt`.
125 /// The engine-seam bridge threads the real attempt so a retry executes with
126 /// attempt-aware handler semantics identical to the gRPC transport; the
127 /// outbox path stamps the row's stored zero-based attempt as one-based.
128 /// Serde-defaulted to `1` so a frame from a pre-attempt server (or an old
129 /// recorded frame) still decodes as a first delivery.
130 #[serde(default = "first_attempt")]
131 pub attempt: u32,
132 /// Engine-provided routing/metadata labels, mirroring the gRPC
133 /// `ActivityTask.labels`. Empty (the serde default) on the outbox path,
134 /// which has no label source.
135 #[serde(default)]
136 pub labels: std::collections::BTreeMap<String, String>,
137 /// The server's heartbeat window in milliseconds when this dispatch is
138 /// tracked by the server's per-task liveness tracker (the engine-seam
139 /// bridge path), or `0` when it is not (the outbox path, whose liveness
140 /// backstop is its own retry loop). A non-zero window tells the worker to
141 /// pump automatic liveness beats at a quarter-window cadence so the
142 /// server's heartbeat sweeper never expires a healthy long-running
143 /// activity — the exact liminal mirror of the gRPC worker's automatic
144 /// liveness pump.
145 #[serde(default)]
146 pub heartbeat_window_ms: u64,
147}
148
149/// Serde default for [`DispatchRequest::attempt`]: a frame that predates the
150/// attempt field is a first delivery.
151const fn first_attempt() -> u32 {
152 1
153}
154
155impl SchemaValidate for DispatchRequest {
156 fn schema_metadata() -> SchemaMetadata {
157 SchemaMetadata::new(
158 "aion.outbox.dispatch.request",
159 "1",
160 br#"{"type":"object"}"#.as_slice(),
161 )
162 }
163}
164
165/// Wire response carrying one worker result back to the outbox.
166///
167/// Mirrors the completion half of the gRPC `ActivityResult`: the correlation ids
168/// plus either a success result or a failure reason. `LiminalCompletionSource`
169/// maps this onto the existing [`OutboxDeliveryCallback`].
170#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
171pub struct DispatchResponse {
172 /// Workflow the completion belongs to.
173 pub workflow_id: WorkflowId,
174 /// Pinned ordinal the completion correlates against.
175 pub ordinal: u64,
176 /// Run that issued the dispatch, echoed back for the run gate.
177 pub run_id: Option<RunId>,
178 /// Opaque execution-generation proof echoed from the request.
179 pub completion_token: String,
180 /// Worker outcome: `Ok(result)` or `Err(reason)`.
181 pub outcome: Result<String, String>,
182}
183
184impl SchemaValidate for DispatchResponse {
185 fn schema_metadata() -> SchemaMetadata {
186 SchemaMetadata::new(
187 "aion.outbox.dispatch.response",
188 "1",
189 br#"{"type":"object"}"#.as_slice(),
190 )
191 }
192}
193
194/// Wire request carrying one neutral mid-run intervention command to a liminal
195/// worker (NOI-6, §6.2).
196///
197/// Rides the SAME liminal server-push channel as [`DispatchRequest`], distinguished
198/// on the wire by its unique required `intervention` field — a plain
199/// [`DispatchRequest`] has no such field, so the worker demuxes the two by which
200/// one deserializes. The whole envelope is neutral: it carries an
201/// [`InterventionCommand`], never a harness type. Field-for-field mirrored by the
202/// worker's `liminal::InterventionRequest`.
203#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
204pub struct InterventionRequest {
205 /// The neutral command to route to the worker owning the target attempt.
206 pub intervention: aion_core::InterventionCommand,
207}
208
209impl SchemaValidate for InterventionRequest {
210 fn schema_metadata() -> SchemaMetadata {
211 SchemaMetadata::new(
212 "aion.intervention.request",
213 "1",
214 br#"{"type":"object"}"#.as_slice(),
215 )
216 }
217}
218
219/// Wire response carrying the worker's neutral intervention ack back to the server
220/// (NOI-6). Field-for-field mirrored by the worker's `liminal::InterventionReply`.
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
222pub struct InterventionReply {
223 /// The neutral applied/gated/stale outcome the operator receives.
224 pub outcome: aion_core::InterventionOutcome,
225}
226
227impl SchemaValidate for InterventionReply {
228 fn schema_metadata() -> SchemaMetadata {
229 SchemaMetadata::new(
230 "aion.intervention.reply",
231 "1",
232 br#"{"type":"object"}"#.as_slice(),
233 )
234 }
235}
236
237/// Reserved liminal channel a worker publishes automatic liveness beats on.
238///
239/// The liminal wire has no gRPC-style heartbeat frame, so per-task liveness
240/// rides a reserved publish channel exactly as the observability transcript
241/// does: the worker's runtime pumps a [`WorkerLivenessBeat`] per in-flight
242/// tracked dispatch at a quarter-window cadence, and the server's
243/// [`LiminalConnectionNotifier`] consumes the channel and refreshes the shared
244/// [`HeartbeatTracker`] — so the #176 expiry sweeper is genuinely
245/// transport-agnostic: a healthy liminal worker running a long activity is
246/// never falsely expired, and a wedged one (which stops pumping) still is.
247/// Mirrored byte-for-byte by the worker crate's constant of the same name.
248pub const WORKER_LIVENESS_CHANNEL: &str = "aion.worker.liveness";
249
250/// Reserved liminal channel a worker announces its intervention capabilities on.
251///
252/// The in-band [`WireWorkerRegistration`] frame is a published liminal protocol
253/// type and cannot carry aion-level capability metadata, so a worker whose
254/// harness supports interventions publishes a [`WorkerCapabilitiesAnnouncement`]
255/// here immediately after registering (once per connection, so a redialed
256/// worker re-announces). The notifier consumes the channel and applies the
257/// announcement to the registered handle — the set the intervention router
258/// gates on and the ops console's live-attempts enumeration reports. Mirrored
259/// byte-for-byte by the worker crate's constant of the same name.
260pub const WORKER_CAPABILITIES_CHANNEL: &str = "aion.worker.capabilities";
261
262/// Wire announcement of a worker's advertised intervention capabilities.
263///
264/// Field-for-field mirror of the worker crate's `WorkerCapabilitiesAnnouncement`
265/// (the same cross-crate contract the dispatch/response pairs pin). The worker
266/// is identified by its CONNECTION (the publish's pid resolves the registered
267/// worker), never by wire-supplied identity, so an announcement can only ever
268/// apply to the worker that sent it.
269#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
270pub struct WorkerCapabilitiesAnnouncement {
271 /// The neutral intervention primitives the worker's harness supports.
272 pub capabilities: aion_core::InterventionCapabilities,
273}
274
275/// Wire liveness beat for one in-flight dispatch (the liminal mirror of the
276/// gRPC `Heartbeat` frame, liveness-only — progress payloads are not carried).
277///
278/// Field-for-field mirror of the worker crate's `WorkerLivenessBeat` (same
279/// serde field names + `aion-core` id types), the same cross-crate contract the
280/// dispatch/response pairs pin. The worker is identified by its CONNECTION (the
281/// publish's pid resolves the registered worker), never by wire-supplied
282/// identity, so a beat can only ever refresh tasks of the worker that sent it.
283#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
284pub struct WorkerLivenessBeat {
285 /// Workflow owning the in-flight activity being kept alive.
286 pub workflow_id: WorkflowId,
287 /// Pinned ordinal of the in-flight activity being kept alive.
288 pub ordinal: u64,
289}
290
291/// Builds the wire request for one claimed outbox row.
292///
293/// Kept free-standing (not a method) so both the dispatch path and tests build
294/// the request the same way.
295#[must_use]
296pub fn request_for_row(
297 row: &OutboxRow,
298 run_id: &RunId,
299 completion_token: &CompletionToken,
300) -> DispatchRequest {
301 let activity_id = ActivityId::from_sequence_position(row.ordinal);
302 DispatchRequest {
303 activity_type: row.activity_type.clone(),
304 workflow_id: row.workflow_id.clone(),
305 ordinal: row.ordinal,
306 run_id: Some(run_id.clone()),
307 completion_token: completion_token.as_str().to_owned(),
308 idempotency_key: idempotency_key(&row.workflow_id, run_id, &activity_id),
309 input: row.input.bytes().to_vec(),
310 // The stored zero-based attempt is stamped one-based on the wire (zero
311 // is malformed), exactly as the gRPC outbox arm's `to_scheduled` does.
312 attempt: row.attempt.saturating_add(1),
313 // Outbox rows carry no engine labels (the gRPC arm sends empty too).
314 labels: std::collections::BTreeMap::new(),
315 // Outbox dispatches are not tracked by the server's per-task liveness
316 // tracker — the outbox retry loop is their liveness backstop — so no
317 // window is assigned and the worker does not pump beats for them.
318 heartbeat_window_ms: 0,
319 }
320}
321
322/// The single reserved character that separates channel segments. Because
323/// `namespace`/`task_queue` are free-form, any occurrence of this byte INSIDE a
324/// segment must be escaped so it cannot be mistaken for the segment boundary.
325const SEGMENT_SEPARATOR: char = '.';
326
327/// The escape character used by [`encode_segment`]. It must itself be escaped so
328/// the encoding stays injective (otherwise `%2E` as a literal field value would
329/// collide with an encoded `.`).
330const SEGMENT_ESCAPE: char = '%';
331
332/// Percent-encodes the two reserved characters (`.` and `%`) inside one channel
333/// segment so distinct segment values can never collide across the join.
334///
335/// This is a minimal, deterministic, per-segment escape: a literal `.` becomes
336/// `%2E` and a literal `%` becomes `%25`; every other byte (including the empty
337/// string) passes through unchanged. Because both the separator AND the escape
338/// char are encoded, the mapping `value -> encoded` is injective: it is exactly
339/// reversible by replacing `%2E -> .` and `%25 -> %`, so two distinct values
340/// can never encode to the same string. Dot-free, percent-free inputs (the
341/// normal case, e.g. `"remote"`, `"gpu"`) are returned byte-for-byte unchanged,
342/// so existing channels are stable.
343fn encode_segment(segment: &str) -> String {
344 // Fast path: nothing reserved, return an owned copy unchanged.
345 if !segment.contains([SEGMENT_SEPARATOR, SEGMENT_ESCAPE]) {
346 return segment.to_owned();
347 }
348 let mut encoded = String::with_capacity(segment.len());
349 for ch in segment.chars() {
350 match ch {
351 // Encode the escape char FIRST so an already-present `%` cannot be
352 // confused with one we introduce for the separator.
353 SEGMENT_ESCAPE => encoded.push_str("%25"),
354 SEGMENT_SEPARATOR => encoded.push_str("%2E"),
355 other => encoded.push(other),
356 }
357 }
358 encoded
359}
360
361/// Derives the liminal dispatch channel for a worker pool addressed
362/// `(namespace, task_queue)`, optionally pinned to a specific `node`.
363///
364/// This is the **single, total source of truth** for the channel string: every
365/// site that needs the channel a `(namespace, task_queue[, node])` pool
366/// dispatches to — both this dispatcher and any future worker-pool subscription
367/// side — MUST call this function so the two sides cannot drift. The format is
368/// `"aion.dispatch.{namespace}.{task_queue}"` for an unpinned dispatch and
369/// `"aion.dispatch.{namespace}.{task_queue}.{node}"` when a `node` is pinned;
370/// each `{segment}` is independently passed through [`encode_segment`].
371///
372/// # The subscriber contract (the seam this function pins, NODE-5 / 13-x)
373///
374/// The subscriber side remains the documented seam (it does not exist yet; 13-0
375/// uses liminal's in-server echo responder). The contract both sides MUST honour:
376///
377/// - An **unpinned** worker pool addressed `(namespace, task_queue)` subscribes
378/// to `dispatch_channel_name(namespace, task_queue, None)` and receives every
379/// unpinned dispatch for that pool.
380/// - A **node-pinned** dispatch (the row carries `Some(node)`) goes to
381/// `dispatch_channel_name(namespace, task_queue, Some(node))`, a DISTINCT
382/// channel. A worker running on that node which is meant to serve pinned work
383/// for the pool MUST ALSO subscribe to that node-specific channel — the
384/// `None` channel alone will never deliver a node-pinned dispatch to it.
385///
386/// Because the `None` channel and any `Some(node)` channel are distinct strings,
387/// a node-pinned dispatch never reaches an unpinned-only subscriber and vice
388/// versa; node isolation is therefore enforced by the channel string itself.
389///
390/// # Injectivity (why the per-segment encode matters)
391///
392/// `namespace`, `task_queue` and `node` are all free-form (the design forbids
393/// preset categories), so a raw `format!` would be NON-injective: a `.` inside
394/// any field bleeds across the separator and pools the design declares disjoint
395/// collide onto one channel — e.g. `("a.b", "c", None)` and `("a", "b.c", None)`
396/// would both yield `aion.dispatch.a.b.c`, a cross-pool leak on the very
397/// isolation dimension this routing exists to keep separate. Encoding each
398/// segment independently (the separator `.` and the escape `%` are escaped within
399/// a segment) makes the map from `(namespace, task_queue, node)` to channel
400/// string injective: distinct triples always yield distinct channels, ACROSS
401/// segment counts too. The node segment is appended only for `Some(node)`, and
402/// because no encoded segment can contain a bare separator, a 2-segment channel
403/// (unpinned) can never be confused with a 3-segment channel (pinned) — e.g.
404/// `("a", "b", Some("c"))` and `("a", "b.c", None)` stay distinct, as do
405/// `("a.b", "c", None)` and `("a", "b", Some("c"))`.
406///
407/// `activity_type` is deliberately NOT part of the channel: it is *what to run*,
408/// matched by the worker after delivery (it rides inside [`DispatchRequest`]),
409/// not *which pool* — see `docs/NAMESPACE-TASKQUEUE-SPLIT-DESIGN.md` §4.2. The
410/// function is total (defined for every input) and stable (the same
411/// `(namespace, task_queue, node)` always yields the same channel).
412#[must_use]
413pub fn dispatch_channel_name(namespace: &str, task_queue: &str, node: Option<&str>) -> String {
414 let namespace = encode_segment(namespace);
415 let task_queue = encode_segment(task_queue);
416 match node {
417 Some(node) => {
418 let node = encode_segment(node);
419 format!("aion.dispatch.{namespace}.{task_queue}.{node}")
420 }
421 None => format!("aion.dispatch.{namespace}.{task_queue}"),
422 }
423}
424
425/// Derives the liminal dispatch channel for a claimed outbox row.
426///
427/// Thin wrapper over [`dispatch_channel_name`] reading the row's durable
428/// `(namespace, task_queue)` (NSTQ-2 columns) and its optional `node` (NODE-2):
429/// when `row.node` is `Some`, the row dispatches to the node-pinned sub-channel;
430/// when `None`, it derives the byte-identical unpinned channel. Kept
431/// free-standing so the dispatch path and tests derive the row's channel
432/// identically.
433#[must_use]
434pub fn channel_for_row(row: &OutboxRow) -> String {
435 dispatch_channel_name(&row.namespace, &row.task_queue, row.node.as_deref())
436}
437
438/// Wraps a reason in the existing worker-dispatch error so a non-deliverable
439/// dispatch drives the outbox's unchanged retry/backoff/dead-letter path. The
440/// row-derived `channel` is surfaced as the `activity_type` field for operator
441/// diagnostics (the field is a free-form context string on this transport's
442/// error).
443fn dispatch_error(channel: &str, reason: String) -> ServerError {
444 ServerError::WorkerDispatch {
445 namespace: "liminal".to_owned(),
446 activity_type: channel.to_owned(),
447 reason,
448 }
449}
450
451/// Receives one worker result over liminal and re-enters it into aion.
452///
453/// Holds the installed [`OutboxDeliveryCallback`] (the same prod
454/// `ServerOutboxDeliveryCallback` the gRPC completion path uses) and maps a
455/// [`DispatchResponse`] onto it, threading `run_id` so the continue-as-new run
456/// gates apply unchanged.
457pub struct LiminalCompletionSource {
458 callback: Arc<dyn OutboxDeliveryCallback>,
459 completion_fences: CompletionFences,
460}
461
462impl std::fmt::Debug for LiminalCompletionSource {
463 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464 f.debug_struct("LiminalCompletionSource")
465 .finish_non_exhaustive()
466 }
467}
468
469impl LiminalCompletionSource {
470 /// Build a completion source over the shared outbox delivery callback.
471 #[must_use]
472 pub fn new(callback: Arc<dyn OutboxDeliveryCallback>) -> Self {
473 Self {
474 callback,
475 completion_fences: CompletionFences::default(),
476 }
477 }
478
479 /// Share the authoritative generation registry used by dispatch.
480 #[must_use]
481 pub fn with_completion_fences(mut self, completion_fences: CompletionFences) -> Self {
482 self.completion_fences = completion_fences;
483 self
484 }
485
486 /// Re-enter one worker result into aion through the delivery callback.
487 ///
488 /// Returns the callback's `bool`: `true` when delivered to a live run,
489 /// `false` when no run is live (the expected stale-completion drop that
490 /// recovery re-arms). A success outcome routes to `deliver_completion`; a
491 /// failure outcome to `deliver_failure`.
492 ///
493 /// # Errors
494 ///
495 /// Returns [`ServerError`] when the response carries an unparseable id or
496 /// the engine rejects the delivery.
497 pub fn deliver(&self, response: &DispatchResponse) -> Result<bool, ServerError> {
498 let run_id = response.run_id.as_ref().ok_or_else(|| {
499 dispatch_error(
500 "liminal completion",
501 "activity result run id is missing; refusing run-gate bypass".to_owned(),
502 )
503 })?;
504 let activity_id = ActivityId::from_sequence_position(response.ordinal);
505 let completion_token = CompletionToken::from_wire(
506 &response.workflow_id,
507 &activity_id,
508 response.completion_token.clone(),
509 )?;
510 if let Err(error) =
511 self.completion_fences
512 .accept(&response.workflow_id, &activity_id, &completion_token)
513 {
514 tracing::warn!(
515 workflow_id = %response.workflow_id,
516 %activity_id,
517 %error,
518 "liminal completion fence rejected a late or stale reply"
519 );
520 return Err(error);
521 }
522 match &response.outcome {
523 Ok(result) => self.callback.deliver_completion(
524 &response.workflow_id,
525 &activity_id,
526 Some(run_id),
527 result.clone(),
528 ),
529 Err(reason) => self.callback.deliver_failure(
530 &response.workflow_id,
531 &activity_id,
532 Some(run_id),
533 reason.clone(),
534 ),
535 }
536 }
537}
538
539/// Rebuilds the activity input payload from the wire request.
540///
541/// The aion side tags activity input as JSON; the wire carries the raw bytes, so
542/// a worker (or the test responder standing in for one) reconstructs the typed
543/// [`Payload`] with the JSON content type.
544#[must_use]
545pub fn payload_from_request(request: &DispatchRequest) -> Payload {
546 Payload::new(ContentType::Json, request.input.clone())
547}
548
549/// Delivery handle for a liminal-connected worker held in the worker registry.
550///
551/// A worker that connects over liminal is a first-class registry member selected
552/// the SAME way as a gRPC worker (`select_worker` on `(namespace, task_queue,
553/// node)`); this is the delivery leg the registry holds for it. It pairs the
554/// [`ConnectionSupervisor`] that owns the worker's connection with that
555/// connection's beamr `pid`, so [`Self::dispatch_held`] can push a
556/// [`DispatchRequest`] out on the worker's existing socket (the LSUB-0
557/// server-push primitive) and block for the correlated [`DispatchResponse`].
558#[derive(Clone)]
559pub struct LiminalWorkerDelivery {
560 supervisor: ConnectionSupervisor,
561 pid: u64,
562}
563
564impl std::fmt::Debug for LiminalWorkerDelivery {
565 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566 f.debug_struct("LiminalWorkerDelivery")
567 .field("pid", &self.pid)
568 .finish_non_exhaustive()
569 }
570}
571
572impl LiminalWorkerDelivery {
573 /// Build a delivery handle for the worker reachable on connection `pid`
574 /// through `supervisor`.
575 #[must_use]
576 pub const fn new(supervisor: ConnectionSupervisor, pid: u64) -> Self {
577 Self { supervisor, pid }
578 }
579
580 /// The connection pid this worker is addressed on.
581 #[must_use]
582 pub const fn pid(&self) -> u64 {
583 self.pid
584 }
585
586 /// Push one outbox dispatch and wait without an activity-duration bound,
587 /// abandoning only when `keep_waiting` becomes false at a poll boundary.
588 ///
589 /// # Errors
590 ///
591 /// Returns the same typed push, receive, and decode failures as the shared
592 /// bridge wait.
593 pub fn dispatch_held(
594 &self,
595 request: &DispatchRequest,
596 keep_waiting: impl Fn() -> bool,
597 ) -> Result<Option<DispatchResponse>, ServerError> {
598 let awaiter = self.push_dispatch(request)?;
599 receive_bridge_reply(&awaiter, keep_waiting)
600 }
601
602 /// Serialize and push one dispatch out on the worker's connection, returning
603 /// the awaiter for its correlated reply — the shared push half of both
604 /// unbounded waits: [`Self::dispatch_held`] on the outbox path and the
605 /// engine-seam bridge dispatcher both use [`receive_bridge_reply`], matching
606 /// the gRPC bridge contract. One frame format, one push primitive, one held
607 /// wait policy.
608 ///
609 /// DELIBERATELY NO REPLY DEADLINE, unlike the liveness and intervention
610 /// pushes. A dispatch's reply IS the activity's completion, so its lifetime
611 /// is the activity's — `remote_gates.awl` declares legs at `timeout 45m`.
612 /// [`receive_bridge_reply`] re-arms on every poll timeout and waits
613 /// unbounded by contract, so this push NEVER abandons its slot and cannot
614 /// leak one: the slot is held for exactly as long as work is outstanding
615 /// and resolves when the reply lands. Attaching a deadline here would kill
616 /// long activities, and would be a regression, not a hardening. The leak
617 /// fixed on the other two sites came from ABANDONING a no-deadline slot,
618 /// which this site never does.
619 ///
620 /// # Errors
621 ///
622 /// Returns [`ServerError::WorkerBusy`] when liminal's typed connection cap
623 /// refuses admission, [`ServerError::WorkerConnectionLost`] for every other
624 /// push-enqueue failure, and [`ServerError::WorkerDispatch`] when the request
625 /// cannot be serialized.
626 pub(crate) fn push_dispatch(
627 &self,
628 request: &DispatchRequest,
629 ) -> Result<PushReplyAwaiter, ServerError> {
630 let payload = serde_json::to_vec(request).map_err(|error| {
631 dispatch_error("liminal-push", format!("request serialize failed: {error}"))
632 })?;
633 self.supervisor
634 .push_to_connection(self.pid, payload)
635 .map_err(|error| classify_push_error(&error))
636 }
637
638 /// Push already-encoded `payload` out on the worker's connection with an
639 /// explicit reply `deadline`, returning the awaiter for its correlated
640 /// reply.
641 ///
642 /// The raw push primitive behind the typed pushes above, used by the
643 /// connection liveness probe
644 /// ([`LivenessProbe`](super::liminal_liveness::LivenessProbe)) so the
645 /// dead-man switch rides the SAME server-push leg a dispatch does — a ping
646 /// that reaches the worker proves the exact path a dispatch would take, not
647 /// a parallel one that could be healthy while the real one is not.
648 ///
649 /// The deadline is LOAD-BEARING and is why this is not
650 /// `push_to_connection`. Riding the dispatch leg means sharing its bounded
651 /// §5 `max_pending_pushes_per_connection` admission, and a no-deadline slot
652 /// is reclaimed only by a consumed reply or a connection close — never by
653 /// the caller giving up. A probe that abandons an unanswered ping every
654 /// cadence therefore LEAKS one slot per round until the cap is exhausted,
655 /// at which point no dispatch, intervention or ping can be pushed on that
656 /// connection again. Measured live on run `dfd2117c`: 32 abandoned pings,
657 /// then permanent refusal, ≈240 s from first blocked ping. The probe built
658 /// to prove the dispatch path works is what destroyed it. With a deadline,
659 /// expiry removes the slot and RELEASES its cap admission.
660 ///
661 /// # Errors
662 ///
663 /// Returns [`ServerError::WorkerBusy`] when liminal's typed connection cap
664 /// refuses admission, and [`ServerError::WorkerConnectionLost`] for every
665 /// other push-enqueue failure.
666 pub fn push_payload_with_deadline(
667 &self,
668 payload: Vec<u8>,
669 deadline: Duration,
670 ) -> Result<PushReplyAwaiter, ServerError> {
671 self.supervisor
672 .push_to_connection_with_deadline(self.pid, payload, deadline)
673 .map_err(|error| classify_push_error(&error))
674 }
675
676 /// Push one neutral intervention command out on the worker's connection and
677 /// block for its correlated ack reply (NOI-6, §6.2).
678 ///
679 /// Uses the same connection as activity dispatch but carries an
680 /// [`InterventionRequest`] and decodes an [`InterventionReply`], so it rides the SAME server-push
681 /// channel as an activity dispatch. The push is a blocking, thread-based liminal
682 /// call; the async router runs it off the runtime.
683 ///
684 /// # Errors
685 ///
686 /// Returns [`ServerError::WorkerConnectionLost`] when the worker connection was
687 /// gone at push time or closed before replying (so the router surfaces the
688 /// too-late no-op); returns [`ServerError::WorkerDispatch`] when the request
689 /// cannot be serialized, the reply times out, or the reply cannot be decoded.
690 pub fn push_intervention(
691 &self,
692 request: &InterventionRequest,
693 ) -> Result<InterventionReply, ServerError> {
694 let payload = serde_json::to_vec(request).map_err(|error| {
695 dispatch_error(
696 "liminal-push",
697 format!("intervention serialize failed: {error}"),
698 )
699 })?;
700 // Deadline-bounded, and it must be: this wait ABANDONS on timeout (the
701 // `receive` error below is terminal, not a re-arm), so a no-deadline
702 // slot would be held until the connection closed and would count
703 // against the connection's §5 pending-push cap forever. The deadline
704 // matches the wait, so an intervention that goes unanswered releases
705 // its admission instead of leaking it. Contrast `push_dispatch`, whose
706 // wait re-arms and is unbounded by contract, and which therefore
707 // correctly takes no deadline.
708 let awaiter = self
709 .supervisor
710 .push_to_connection_with_deadline(self.pid, payload, PUSH_REPLY_TIMEOUT)
711 .map_err(|error| {
712 ServerError::worker_connection_lost(
713 "liminal-push",
714 format!("push intervention to worker failed: {error}"),
715 )
716 })?;
717 let reply = awaiter.receive(PUSH_REPLY_TIMEOUT).map_err(|error| {
718 if is_connection_closed_reply_error(&error) {
719 ServerError::worker_connection_lost(
720 "liminal-push",
721 format!("worker connection closed before intervention ack: {error}"),
722 )
723 } else {
724 dispatch_error("liminal-push", format!("intervention ack failed: {error}"))
725 }
726 })?;
727 serde_json::from_slice(&reply).map_err(|error| {
728 dispatch_error(
729 "liminal-push",
730 format!("intervention ack decode failed: {error}"),
731 )
732 })
733 }
734}
735
736/// Decodes one correlated reply payload as a [`DispatchResponse`].
737///
738/// Shared by the outbox wait ([`LiminalWorkerDelivery::dispatch_held`]) and the
739/// bridge wait ([`receive_bridge_reply`]) so the two paths can never diverge on
740/// the wire's reply shape.
741fn decode_dispatch_response(reply: &[u8]) -> Result<DispatchResponse, ServerError> {
742 serde_json::from_slice(reply).map_err(|error| {
743 dispatch_error(
744 "liminal-push",
745 format!("worker reply decode failed: {error}"),
746 )
747 })
748}
749
750/// Blocks for the correlated reply to an engine-seam BRIDGE dispatch push, with
751/// the bridge's UNBOUNDED wait contract: the engine imposes no activity timeout
752/// of its own, so an elapsed [`BRIDGE_REPLY_POLL`] merely re-arms the wait — the
753/// exact liminal mirror of the gRPC bridge's unbounded `recv`, which is released
754/// only by a completion or by stream teardown. The wait terminates on exactly:
755///
756/// - **the reply** — decoded as the worker's [`DispatchResponse`] and returned
757/// as `Ok(Some(response))`. A reply already buffered when a poll fires always
758/// wins over abandonment: the awaiter is drained before `keep_waiting` runs;
759/// - **abandonment** — `keep_waiting()` returned `false` at a poll boundary
760/// (the bridge passes "is this dispatch still tracked in-flight?"): the
761/// dispatch was resolved by another path (expiry sweep, shutdown drain, or a
762/// cleanup), so the wait returns `Ok(None)` and the router thread exits
763/// instead of parking on the connection indefinitely;
764/// - **worker loss** — the connection closed before replying: the awaiter wakes
765/// PROMPTLY with liminal's typed Disconnected error, surfaced as
766/// [`ServerError::WorkerConnectionLost`] so the bridge reports the same
767/// TRANSPORT-loss class the gRPC teardown sweep does;
768/// - **an unrecognized receive fault or a decode failure** — surfaced as
769/// [`ServerError::WorkerDispatch`].
770///
771/// Runs on a dedicated bridge reply thread, never on an async runtime worker.
772///
773/// # Errors
774///
775/// Returns [`ServerError::WorkerConnectionLost`] on worker loss and
776/// [`ServerError::WorkerDispatch`] on a receive fault or reply decode failure.
777pub(crate) fn receive_bridge_reply(
778 awaiter: &PushReplyAwaiter,
779 keep_waiting: impl Fn() -> bool,
780) -> Result<Option<DispatchResponse>, ServerError> {
781 loop {
782 match awaiter.receive(BRIDGE_REPLY_POLL) {
783 Ok(reply) => return decode_dispatch_response(&reply).map(Some),
784 // A bare poll timeout re-arms the wait while the dispatch is still
785 // outstanding (the wait is unbounded by contract; see the bridge
786 // module docs), and ends it cleanly once the dispatch was resolved
787 // elsewhere — the poll cadence bounds the router thread's lifetime
788 // to one poll past that resolution.
789 Err(LiminalServerError::PushReplyTimeout { .. }) => {
790 if !keep_waiting() {
791 return Ok(None);
792 }
793 }
794 Err(error) if is_connection_closed_reply_error(&error) => {
795 return Err(ServerError::worker_connection_lost(
796 "liminal-push",
797 format!("worker connection closed before reply: {error}"),
798 ));
799 }
800 Err(error) => {
801 return Err(dispatch_error(
802 "liminal-push",
803 format!("worker reply failed: {error}"),
804 ));
805 }
806 }
807 }
808}
809
810/// Returns true when a liminal push-reply error is the *Disconnected* case (the
811/// worker's connection closed before it replied), as opposed to a genuine reply
812/// timeout (the worker is alive but slow).
813///
814/// Liminal returns these as distinct TYPED variants —
815/// `ServerError::PushReplyDisconnected` vs `PushReplyTimeout` (see `liminal-server`
816/// `supervisor.rs` `PushReplyAwaiter::receive`) — so this is a type match, not a
817/// message-text match: a worker that DIED (connection-lost, fast failover) is told
818/// apart from one that is merely SLOW (genuine timeout, normal backoff) by variant.
819fn is_connection_closed_reply_error(error: &LiminalServerError) -> bool {
820 matches!(error, LiminalServerError::PushReplyDisconnected { .. })
821}
822
823/// Classify a typed liminal push refusal without relying on error text.
824fn classify_push_error(error: &LiminalServerError) -> ServerError {
825 if is_connection_cap_error(error) {
826 ServerError::worker_busy(
827 "liminal-push",
828 format!("worker connection push cap reached: {error}"),
829 )
830 } else {
831 ServerError::worker_connection_lost(
832 "liminal-push",
833 format!("push to worker failed: {error}"),
834 )
835 }
836}
837
838/// Liminal's typed admission refusal when a connection has too many held pushes.
839fn is_connection_cap_error(error: &LiminalServerError) -> bool {
840 matches!(error, LiminalServerError::ConnectionCapReached { .. })
841}
842
843/// Cross-node [`OutboxRowDispatch`] that selects a liminal worker from the
844/// connected-worker registry and pushes the row to it.
845///
846/// This is the LSUB-1 server-side composition: for each claimed row it selects a
847/// worker by the row's `(namespace, task_queue, activity_type, node)` via the
848/// EXISTING registry `select_worker` (the same selection the gRPC path uses, so
849/// routing semantics are shared), pushes the [`DispatchRequest`] to that worker's
850/// liminal connection via its [`LiminalWorkerDelivery`], and re-enters the
851/// worker's [`DispatchResponse`] through the SAME [`LiminalCompletionSource`] /
852/// [`OutboxDeliveryCallback`] the existing completion path uses. A row that
853/// reaches no matching worker, or whose worker is not liminal-delivered, returns
854/// an error so the outbox's unchanged retry/backoff drives it — the same honest
855/// no-worker contract as the gRPC path.
856pub struct RegistryLiminalDispatch {
857 registry: ConnectedWorkerRegistry,
858 completion: LiminalCompletionSource,
859 delivery_gate: DeliveryGate,
860 /// Optional short-TTL per-namespace placement cache (Control-Plane Phase 2,
861 /// P2-P3), the SAME cache the gRPC
862 /// [`WorkerOutboxDispatch`](crate::worker::WorkerOutboxDispatch) is given. When
863 /// present, an UNPINNED row (`row.node == None`) whose namespace placement is
864 /// `Prefer{L}` selects an L-labelled worker and spills to any live worker when
865 /// none is up, via the SHARED
866 /// [`preferred_node_order`](crate::worker::preferred_node_order). When absent
867 /// (the default, every pre-Phase-2 construction and test) selection is
868 /// byte-identical to before: one `select_worker` off the row's own node.
869 /// Placement is NEVER stamped back onto the row — it is consulted only here,
870 /// in this non-replayed dispatcher, for worker selection.
871 placement_cache: Option<crate::worker::PlacementCache>,
872 /// Optional NOI-6 `attempt -> owning-worker` back-index. When installed via
873 /// [`Self::with_attempt_owners`], each dispatched agent attempt binds its
874 /// `(workflow, activity, attempt)` to the selected worker here BEFORE the push and
875 /// releases it after the reply, so the server's intervention router resolves the
876 /// CURRENT owner of a live attempt. `None` (the default, and every non-agent
877 /// deployment) skips the binding — intervention is simply never offered.
878 attempt_owners: Option<super::intervention::AttemptOwnerIndex>,
879}
880
881impl std::fmt::Debug for RegistryLiminalDispatch {
882 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
883 f.debug_struct("RegistryLiminalDispatch")
884 .field("placement_cache", &self.placement_cache.is_some())
885 .finish_non_exhaustive()
886 }
887}
888
889/// One selection attempt: the worker it found, and the node filters it WALKED.
890///
891/// The tiers travel WITH the outcome because a miss can only be explained over
892/// the filters selection actually applied. Recomputing them at the refusal site
893/// is how the two answers drift: the recomputation has no access to the
894/// namespace placement the selection resolved, so it silently substitutes the
895/// row's own node and describes a different fleet from the one selection saw.
896struct SelectionAttempt {
897 /// The selected worker, or `None` when no tier held an eligible one.
898 worker: Option<WorkerHandle>,
899 /// The ordered node filters walked, in the order they were tried. For a
900 /// `Pinned{L}` namespace these are the required labels and nothing else —
901 /// there is deliberately no `None` any-node tier to fall back on.
902 tiers: Vec<Option<String>>,
903}
904
905impl RegistryLiminalDispatch {
906 /// Build a registry-backed liminal dispatch that re-enters worker results
907 /// through `callback` (the shared `ServerOutboxDeliveryCallback`).
908 #[must_use]
909 pub fn new(
910 registry: ConnectedWorkerRegistry,
911 callback: Arc<dyn OutboxDeliveryCallback>,
912 delivery_gate: DeliveryGate,
913 ) -> Self {
914 Self {
915 registry,
916 completion: LiminalCompletionSource::new(callback),
917 delivery_gate,
918 placement_cache: None,
919 attempt_owners: None,
920 }
921 }
922
923 /// Share completion-generation authority with result ingestion.
924 #[must_use]
925 pub fn with_completion_fences(mut self, completion_fences: CompletionFences) -> Self {
926 self.completion = self.completion.with_completion_fences(completion_fences);
927 self
928 }
929
930 /// Install the NOI-6 attempt-owner back-index so each dispatched attempt binds
931 /// its owning worker for the intervention router to resolve (NOI-6).
932 ///
933 /// The SAME index the server's [`InterventionRouter`](super::intervention::InterventionRouter)
934 /// resolves through (from `ServerState::attempt_owners`), so a pushed command
935 /// reaches the worker this dispatcher sent the attempt to. Pure builder addition:
936 /// without it, no ownership is recorded and the router finds no owner (the
937 /// too-late no-op), exactly as before.
938 #[must_use]
939 pub fn with_attempt_owners(
940 mut self,
941 attempt_owners: super::intervention::AttemptOwnerIndex,
942 ) -> Self {
943 self.attempt_owners = Some(attempt_owners);
944 self
945 }
946
947 /// Attach the per-namespace placement cache so an unpinned row consults its
948 /// namespace's `Prefer` directive at selection time (Control-Plane Phase 2,
949 /// P2-P3) — the liminal mirror of
950 /// [`WorkerOutboxDispatch::with_placement_cache`](crate::worker::WorkerOutboxDispatch::with_placement_cache).
951 /// Pure builder addition: without it, selection is byte-identical to the
952 /// pre-Phase-2 behaviour.
953 #[must_use]
954 pub fn with_placement_cache(mut self, cache: crate::worker::PlacementCache) -> Self {
955 self.placement_cache = Some(cache);
956 self
957 }
958
959 /// Select the liminal worker for `row`, applying the SHARED placement decision
960 /// for an UNPINNED row when a placement cache is attached — the exact gRPC
961 /// semantics ([`worker_selection_for`](crate::worker::worker_selection_for)):
962 /// `Prefer{L}` spills to any live worker, `Pinned{L}` requires an L-labelled
963 /// worker and NEVER spills to a node=None any-worker.
964 ///
965 /// A per-activity authored pin (`row.node == Some(N)`) ALWAYS wins and is
966 /// selected off the row's own node, untouched by placement — exactly the gRPC
967 /// composition rule. Without a cache (or with a pinned row) this collapses to
968 /// the single `select_worker` off the row's own node — the pre-Phase-2
969 /// behaviour.
970 ///
971 /// For `Pinned{L}`, when no L-labelled worker is live this returns `Ok(None)` —
972 /// NOT a spill to a node=None worker — so the [`OutboxRowDispatch`] surfaces the
973 /// honest no-worker error and the outbox retries/stalls until an L-labelled
974 /// worker returns, mirroring the gRPC wait-for-worker path exactly (both
975 /// transports agree via [`WorkerSelection`](crate::worker::WorkerSelection)).
976 async fn select_liminal_worker(
977 &self,
978 row: &OutboxRow,
979 ) -> Result<SelectionAttempt, ServerError> {
980 // A pinned row or an absent cache: one selection off the row's own node.
981 let (Some(cache), None) = (&self.placement_cache, &row.node) else {
982 let tiers = vec![row.node.clone()];
983 let worker = self.registry.select_worker(
984 &row.namespace,
985 &row.task_queue,
986 &row.activity_type,
987 row.node.as_deref(),
988 )?;
989 return Ok(SelectionAttempt { worker, tiers });
990 };
991 // Unpinned + placement-aware: resolve the shared selection decision, so this
992 // liminal path and the gRPC path can never diverge on Prefer-vs-Pinned. The
993 // row's `node` is never mutated — selection is a pure dispatch-time input.
994 let placement = cache.placement(&row.namespace).await;
995 let tiers: Vec<Option<String>> = match crate::worker::worker_selection_for(&placement) {
996 // Prefer/Unplaced: walk the prefer-then-spill tiers (the `None` spill is
997 // always last), stopping at the first tier with a live worker.
998 crate::worker::WorkerSelection::PreferTiers(tiers) => tiers,
999 // Pinned{L}: try ONLY the required labels — no `None` spill. When none is
1000 // live, select nothing so the caller retries/stalls (never any-node).
1001 crate::worker::WorkerSelection::Required(required) => {
1002 required.into_iter().map(Some).collect()
1003 }
1004 };
1005 let worker = self.select_over_tiers(row, tiers.iter().map(Option::as_deref))?;
1006 Ok(SelectionAttempt { worker, tiers })
1007 }
1008
1009 /// Select the first live worker over an ordered sequence of node filters,
1010 /// returning `Ok(None)` when no filter matches a live worker. Shared by the
1011 /// `Prefer` (tiers end in a `None` spill) and `Pinned` (required labels only,
1012 /// no spill) selection arms so both walk the registry identically.
1013 fn select_over_tiers<'a>(
1014 &self,
1015 row: &OutboxRow,
1016 tiers: impl Iterator<Item = Option<&'a str>>,
1017 ) -> Result<Option<WorkerHandle>, ServerError> {
1018 for tier in tiers {
1019 let selected = self.registry.select_worker(
1020 &row.namespace,
1021 &row.task_queue,
1022 &row.activity_type,
1023 tier,
1024 )?;
1025 if selected.is_some() {
1026 return Ok(selected);
1027 }
1028 }
1029 Ok(None)
1030 }
1031
1032 /// Why this row found no worker — stated as the fact it IS (#197 R3).
1033 ///
1034 /// A selection miss has two causes an operator must act on differently, and
1035 /// the single catch-all this replaces conflated them into a misdiagnosis:
1036 ///
1037 /// - **nobody is there.** No worker registered for the row's address. The
1038 /// remedy is to start one.
1039 /// - **workers are there and none is currently eligible.** They registered
1040 /// and the liveness verdict excludes them — each is either still serving
1041 /// its opening probation (ordinary, self-clearing within a couple of
1042 /// probe cadences) or has had its eligibility withdrawn (an incident, and
1043 /// the WARN naming it is already in the log). The remedy is to read that
1044 /// verdict, and NEVER to start another worker — a second one would serve
1045 /// the same probation and change nothing.
1046 ///
1047 /// The distinction became newly visible when the probe learned to reach
1048 /// gRPC-delivered workers: before, no gRPC worker was ever pinged, so its
1049 /// exclusion was permanent and invisible behind "nobody is registered".
1050 ///
1051 /// `tiers` MUST be the sequence [`Self::select_liminal_worker`] actually
1052 /// walked, which is why it travels here from the selection rather than
1053 /// being re-derived. A `Pinned{L}` namespace walks the required labels and
1054 /// never spills to a `None` any-node tier; counting over the row's own node
1055 /// instead would match every worker in the pool and blame the miss on an
1056 /// unlabelled worker that was never a candidate — telling the operator not
1057 /// to start the labelled worker that is the only remedy.
1058 ///
1059 /// A census read failure falls back to the bare miss, LOUDLY. Losing the
1060 /// REASON must never suppress the refusal itself, and inventing an
1061 /// exclusion count this call could not read would be worse than saying
1062 /// less — but a poisoned registry lock is a corruption signal that must not
1063 /// leave only a quieter refusal behind as its trace.
1064 fn selection_miss_reason(&self, row: &OutboxRow, tiers: &[Option<String>]) -> String {
1065 let excluded = match self.registry.ineligible_workers_over_tiers(
1066 &row.namespace,
1067 &row.task_queue,
1068 &row.activity_type,
1069 tiers,
1070 ) {
1071 Ok(excluded) => excluded,
1072 Err(error) => {
1073 tracing::warn!(
1074 %error,
1075 namespace = %row.namespace,
1076 task_queue = %row.task_queue,
1077 activity_type = %row.activity_type,
1078 "could not read the dispatch-eligibility census for a selection miss; this \
1079 refusal falls back to the bare no-worker message and may therefore describe \
1080 a pool whose workers are merely EXCLUDED as though it were empty"
1081 );
1082 0
1083 }
1084 };
1085 if excluded == 0 {
1086 return "no liminal worker registered for the row's pool".to_owned();
1087 }
1088 format!(
1089 "{excluded} worker(s) are registered for the row's pool but NONE is currently \
1090 dispatch-eligible: each is either serving its opening liveness probation or has had \
1091 its eligibility withdrawn. This is not an empty pool — starting another worker will \
1092 not help. Read the liveness verdict for these workers (the probation/withdrawal lines \
1093 carry the worker ids and the reason)"
1094 )
1095 }
1096
1097 fn revoke_completion(
1098 &self,
1099 row: &OutboxRow,
1100 activity_id: &ActivityId,
1101 completion_token: &CompletionToken,
1102 ) -> Result<(), ServerError> {
1103 self.completion
1104 .completion_fences
1105 .revoke(&row.workflow_id, activity_id, completion_token)
1106 }
1107}
1108
1109#[async_trait]
1110impl OutboxRowDispatch for RegistryLiminalDispatch {
1111 async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
1112 // Select the worker the SAME way the gRPC path does: by the row's
1113 // (namespace, task_queue, activity_type) pool key with the row's optional
1114 // node affinity, applying the SHARED `Prefer` two-tier spill for an
1115 // unpinned row when a placement cache is attached. No worker for the pool
1116 // => honest no-worker error => the outbox retries (never a false `done`).
1117 let attempt = self.select_liminal_worker(row).await?;
1118 let Some(worker) = attempt.worker else {
1119 return Err(dispatch_error(
1120 &channel_for_row(row),
1121 self.selection_miss_reason(row, &attempt.tiers),
1122 ));
1123 };
1124
1125 let delivery = match worker.delivery() {
1126 WorkerDelivery::Liminal(delivery) => delivery.clone(),
1127 WorkerDelivery::Grpc(_) => {
1128 return Err(dispatch_error(
1129 &channel_for_row(row),
1130 "selected worker is not delivered over liminal".to_owned(),
1131 ));
1132 }
1133 };
1134
1135 // The run is resolved BEFORE the owner binding, because the binding is
1136 // keyed on it: a run-blind key would let an intervention aimed at one
1137 // continue-as-new generation resolve another's worker. The refusal is
1138 // the pre-existing one — a row without a run cannot be dispatched at all
1139 // — simply moved ahead of the bind it now feeds.
1140 let activity_id = ActivityId::from_sequence_position(row.ordinal);
1141 let run_id = row.run_id.as_ref().ok_or_else(|| {
1142 dispatch_error(
1143 &channel_for_row(row),
1144 "activity run id is missing; refusing unfenced external effect".to_owned(),
1145 )
1146 })?;
1147
1148 // NOI-6: bind this attempt's owner BEFORE the push, so an intervention that
1149 // races the dispatch resolves the worker. The guard releases on every exit
1150 // path (reply, error, panic) so the index never keeps a finished attempt.
1151 // The key mirrors the worker's execute-path stamp exactly: activity_id from
1152 // the ordinal, run_id from the row, attempt = the wire's one-based delivery
1153 // attempt (the same `request_for_row` stamp the worker echoes into its
1154 // session key). See `LiminalActivityWorker::execute` / `run_agent_dispatch`.
1155 let _owner_guard = self.attempt_owners.as_ref().map(|owners| {
1156 AttemptOwnerGuard::bind(
1157 owners.clone(),
1158 super::intervention::AttemptKey::new(
1159 row.workflow_id.clone(),
1160 run_id.clone(),
1161 activity_id.clone(),
1162 row.attempt.saturating_add(1),
1163 ),
1164 worker.id(),
1165 )
1166 });
1167
1168 // Push the dispatch to the worker and block for its correlated reply. The
1169 // push is a blocking, thread-based liminal call; run it off the async
1170 // runtime so a long-running activity cannot starve a runtime worker.
1171 let completion_token = self
1172 .completion
1173 .completion_fences
1174 .issue(&row.workflow_id, &activity_id)?;
1175 let request = request_for_row(row, run_id, &completion_token);
1176 let gate = self.delivery_gate.clone();
1177 let dispatch_key = row.dispatch_key.clone();
1178 let registry = self.registry.clone();
1179 let worker_id = worker.id();
1180 let dispatched = tokio::task::spawn_blocking(move || {
1181 delivery.dispatch_held(&request, || {
1182 if gate.is_draining() || !gate.holds(&dispatch_key) {
1183 return false;
1184 }
1185 match registry.worker_by_id(worker_id) {
1186 Ok(Some(_)) => true,
1187 Ok(None) => false,
1188 Err(error) => {
1189 tracing::warn!(
1190 %error,
1191 %dispatch_key,
1192 "delivery wait could not verify worker registration; abandoning"
1193 );
1194 false
1195 }
1196 }
1197 })
1198 })
1199 .await
1200 .map_err(|error| {
1201 dispatch_error(
1202 &channel_for_row(row),
1203 format!("dispatch task join failed: {error}"),
1204 )
1205 });
1206 let response = match dispatched {
1207 Ok(Ok(Some(response))) => response,
1208 Ok(Ok(None)) => {
1209 tracing::warn!(
1210 dispatch_key = %row.dispatch_key,
1211 workflow_id = %row.workflow_id,
1212 %activity_id,
1213 "delivery wait abandoned; late reply will be discarded"
1214 );
1215 self.revoke_completion(row, &activity_id, &completion_token)?;
1216 return Err(dispatch_error(
1217 &channel_for_row(row),
1218 "delivery wait abandoned before worker reply".to_owned(),
1219 ));
1220 }
1221 Ok(Err(error)) | Err(error) => {
1222 self.revoke_completion(row, &activity_id, &completion_token)?;
1223 return Err(error);
1224 }
1225 };
1226
1227 // Re-enter the worker's result through the SAME completion path the gRPC
1228 // transport uses (terminal dedup in `record_fan_out_completion` applies
1229 // unchanged). The dispatch itself succeeded — the row's terminal state is
1230 // recorded by the completion callback, exactly as in the gRPC path.
1231 if let Err(error) = self.completion.deliver(&response) {
1232 self.revoke_completion(row, &activity_id, &completion_token)?;
1233 return Err(error);
1234 }
1235 Ok(())
1236 }
1237}
1238
1239/// RAII guard that releases an [`AttemptOwnerIndex`](super::intervention::AttemptOwnerIndex)
1240/// binding when the dispatch resolves — on the reply, an error, or a panic — so
1241/// the back-index tracks exactly the attempts currently in flight (NOI-6).
1242///
1243/// Shared by both liminal dispatch arms: the outbox row wait holds it across
1244/// its blocking `dispatch` call, and the engine-seam bridge hands it to the
1245/// dispatch's reply-router thread, which drops it on every exit path.
1246pub(crate) struct AttemptOwnerGuard {
1247 owners: super::intervention::AttemptOwnerIndex,
1248 key: super::intervention::AttemptKey,
1249}
1250
1251impl AttemptOwnerGuard {
1252 /// Bind `key` to `worker` in `owners` and return the guard that releases
1253 /// the binding on drop.
1254 pub(crate) fn bind(
1255 owners: super::intervention::AttemptOwnerIndex,
1256 key: super::intervention::AttemptKey,
1257 worker: super::registry::WorkerId,
1258 ) -> Self {
1259 owners.bind(key.clone(), worker);
1260 Self { owners, key }
1261 }
1262}
1263
1264impl Drop for AttemptOwnerGuard {
1265 fn drop(&mut self) {
1266 self.owners.release(&self.key);
1267 }
1268}
1269
1270/// Normalize a wire `node` (`Option<String>`) onto the registry's optional
1271/// locality affinity, applying the SAME none-convention the gRPC registration
1272/// path uses (`registry::optional_node`): an empty string carries no node, so it
1273/// collapses to `None`; any non-empty value is the worker's advertised node.
1274///
1275/// The wire already models `node` as `Option<String>`, but a worker that joins
1276/// `Some("")` (the empty-string node) must not register a distinct empty-node
1277/// affinity that no pinned dispatch could ever match — it is semantically
1278/// unpinned, exactly as the gRPC proto3 empty default is. Folding it to `None`
1279/// here keeps the two registration paths byte-for-byte equivalent.
1280fn normalize_wire_node(node: Option<&str>) -> Option<String> {
1281 node.filter(|value| !value.is_empty())
1282 .map(ToOwned::to_owned)
1283}
1284
1285/// Connection-keyed [`ConnectionNotifier`] that turns liminal's in-band worker
1286/// registration into a first-class [`ConnectedWorkerRegistry`] membership.
1287///
1288/// This is the SERVER half of LSUB-L2: when a worker connects with a
1289/// [`WireWorkerRegistration`] (the SDK's `connect_with_registration`), liminal's
1290/// connection process invokes [`on_worker_registered`](Self::on_worker_registered)
1291/// with the connection's beamr `pid` and the worker's declared
1292/// `(namespaces, task_queue, node, activity_types)`. The notifier builds a
1293/// [`WorkerDelivery::Liminal`] over the connection and inserts it into the
1294/// registry — the SAME registry entry, selected the SAME way, as a gRPC worker —
1295/// retiring the LSUB-1 out-of-band `active_connection_pids()` + hard-coded
1296/// registration hack.
1297///
1298/// # Lifetime of the registration guard
1299///
1300/// [`ConnectedWorkerRegistry::register_delivery`] returns a
1301/// [`WorkerRegistration`] guard whose drop deregisters the worker. The notifier
1302/// OWNS that guard keyed by `pid` (`Mutex<HashMap<u64, WorkerRegistration>>`), so
1303/// the registration lives exactly as long as the connection: it is inserted on
1304/// register and removed (dropped) on
1305/// [`on_worker_unregistered`](Self::on_worker_unregistered), which liminal fires
1306/// on connection close.
1307///
1308/// # Construction-order cycle (notifier <-> supervisor)
1309///
1310/// [`LiminalWorkerDelivery`] needs a [`ConnectionSupervisor`] handle to push to
1311/// the worker's connection, but the supervisor is itself constructed WITH this
1312/// notifier ([`ConnectionSupervisor::with_services_and_notifier`]) — a cycle. The
1313/// notifier therefore holds the supervisor behind a [`OnceLock`], populated
1314/// IMMEDIATELY after the supervisor is built via [`Self::bind_supervisor`]. The
1315/// `OnceLock` is never read before it is set in correct wiring (a worker can only
1316/// register after the listener — built after the supervisor and after
1317/// `bind_supervisor` — accepts its connection); if it somehow were, registration
1318/// is REJECTED with a typed error rather than panicking, so there is no
1319/// production `unwrap`/`expect` and no second always-`None` code path.
1320pub struct LiminalConnectionNotifier {
1321 registry: ConnectedWorkerRegistry,
1322 contract_catalog: Option<Arc<aion::Engine>>,
1323 supervisor: OnceLock<ConnectionSupervisor>,
1324 guards: Mutex<HashMap<u64, WorkerRegistration>>,
1325 /// The neutral intervention primitives a liminal-connected agent worker
1326 /// advertises (NOI-6, item 4). The liminal `WorkerRegistration` wire has a fixed
1327 /// shape that cannot carry this, so it is configured on the notifier at the
1328 /// composition root from the harness's advertised `AgentSession::capabilities()`
1329 /// and recorded on every registered worker's handle, where the intervention
1330 /// router gates on it. Default empty = observability-only (a plain activity
1331 /// worker), so the router offers no controls for it.
1332 intervention_capabilities: aion_core::InterventionCapabilities,
1333 /// The transcript sequencer a worker's observability publishes drain into
1334 /// (NOI-5b), plus the Tokio [`Handle`](tokio::runtime::Handle) to bridge the
1335 /// synchronous connection-process callback onto the async publish. `None` (the
1336 /// default, and every non-agent boot) makes the observability tap a no-op, so a
1337 /// worker publish to the reserved channel is simply ignored by the notifier.
1338 transcript: Option<TranscriptTap>,
1339 /// The SAME per-task liveness tracker the bridge dispatcher tracks into and
1340 /// the #176 expiry sweeper expires from. A worker's automatic
1341 /// [`WorkerLivenessBeat`] publishes on [`WORKER_LIVENESS_CHANNEL`] refresh
1342 /// their task stamps here, keeping the sweeper honest for liminal-delivered
1343 /// dispatches. `None` (a wiring that never bridges dispatches, e.g. isolated
1344 /// tests) consumes and drops the beats.
1345 heartbeat_tracker: Option<super::heartbeat::HeartbeatTracker>,
1346}
1347
1348/// The observability-drain leg of the notifier: a bounded, ORDERED queue into
1349/// the one drain task that publishes transcript events sequentially.
1350///
1351/// One consumer is load-bearing, not an implementation detail: a spawned task
1352/// per event (the pre-2026-07-23 shape) made every in-flight frame a
1353/// concurrent writer racing the same stream head, turning the sequencer's
1354/// optimistic-append loop into an O(N²) conflict stampede under load — and
1355/// destroyed `worker_seq` order on the durable transcript. Sequential
1356/// draining preserves arrival order and leaves the conflict-retry loop to
1357/// handle only genuine cross-process races (failover adoption).
1358#[derive(Clone)]
1359struct TranscriptTap {
1360 queue: tokio::sync::mpsc::Sender<aion_core::ActivityEvent>,
1361}
1362
1363/// Bound on the transcript drain queue: events a slow durable append cannot
1364/// keep up with are dropped (with a warning) rather than buffered without
1365/// limit. Live streaming is best-effort; the bound protects server memory.
1366const TRANSCRIPT_QUEUE_CAPACITY: usize = 4096;
1367
1368impl std::fmt::Debug for LiminalConnectionNotifier {
1369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1370 f.debug_struct("LiminalConnectionNotifier")
1371 .field("supervisor_bound", &self.supervisor.get().is_some())
1372 .finish_non_exhaustive()
1373 }
1374}
1375
1376impl LiminalConnectionNotifier {
1377 /// Build a notifier that registers connecting workers into `registry`.
1378 ///
1379 /// The supervisor handle is bound separately via [`Self::bind_supervisor`]
1380 /// immediately after the supervisor is constructed, resolving the
1381 /// notifier <-> supervisor construction cycle (see the type docs).
1382 #[must_use]
1383 pub fn new(registry: ConnectedWorkerRegistry) -> Self {
1384 Self {
1385 registry,
1386 contract_catalog: None,
1387 supervisor: OnceLock::new(),
1388 guards: Mutex::new(HashMap::new()),
1389 intervention_capabilities: aion_core::InterventionCapabilities::none(),
1390 transcript: None,
1391 heartbeat_tracker: None,
1392 }
1393 }
1394
1395 /// Install the live package catalog used to refuse incompatible workers
1396 /// before liminal acknowledges or publishes their registration.
1397 #[must_use]
1398 pub fn with_contract_catalog(mut self, engine: Arc<aion::Engine>) -> Self {
1399 self.contract_catalog = Some(engine);
1400 self
1401 }
1402
1403 /// Install the shared per-task liveness tracker so a worker's automatic
1404 /// [`WorkerLivenessBeat`] publishes refresh the SAME in-flight entries the
1405 /// bridge dispatcher tracks and the #176 sweeper expires.
1406 ///
1407 /// MUST be wired on every boot that hosts the engine-seam bridge (the
1408 /// production composition does), or the sweeper would expire healthy
1409 /// liminal workers running activities longer than the heartbeat window.
1410 /// Without it (isolated tests that never bridge-dispatch) beats are
1411 /// consumed and dropped.
1412 #[must_use]
1413 pub fn with_heartbeat_tracker(mut self, tracker: super::heartbeat::HeartbeatTracker) -> Self {
1414 self.heartbeat_tracker = Some(tracker);
1415 self
1416 }
1417
1418 /// Install the transcript sequencer a worker's observability publishes drain into
1419 /// (NOI-5b), capturing the CURRENT Tokio runtime handle to bridge the synchronous
1420 /// connection-process callback onto the async publish.
1421 ///
1422 /// MUST be called from within a Tokio runtime (the server boot path is), so the
1423 /// captured [`Handle`](tokio::runtime::Handle) can spawn the append+fan-out when a
1424 /// worker publishes a transcript event over the reserved channel. Without this
1425 /// builder the observability tap is a no-op (a plain, non-agent deployment).
1426 ///
1427 /// # Panics
1428 ///
1429 /// Panics if called outside a Tokio runtime — a construction-time wiring error in
1430 /// the server boot, never a runtime condition (the boot path always builds the
1431 /// notifier inside the server runtime).
1432 #[must_use]
1433 pub fn with_transcript_publisher(
1434 mut self,
1435 publisher: crate::activity_publisher::ActivityEventPublisher,
1436 ) -> Self {
1437 let (queue, mut events) =
1438 tokio::sync::mpsc::channel::<aion_core::ActivityEvent>(TRANSCRIPT_QUEUE_CAPACITY);
1439 // The ONE drain task: transcript events publish sequentially in
1440 // arrival order (see the `TranscriptTap` docs for why one consumer is
1441 // load-bearing). It lives as long as any queue sender does.
1442 //
1443 // The drain COALESCES under the operator's flush policy: the events
1444 // waiting on this queue are committed as batches, so a chatty agent
1445 // costs one storage-tree commit per batch instead of one per event —
1446 // and one commit is one whole-leaf rewrite (forensics 2026-08-17).
1447 // Arrival order is untouched; only the number of commits changes.
1448 tokio::runtime::Handle::current().spawn(async move {
1449 let dropped = publisher.drain(&mut events, "observability_tap").await;
1450 if dropped > 0 {
1451 tracing::warn!(
1452 dropped,
1453 operation = "observability_tap",
1454 "observability tap: transcript events were not retained"
1455 );
1456 }
1457 });
1458 self.transcript = Some(TranscriptTap { queue });
1459 self
1460 }
1461
1462 /// Set the neutral intervention capability set every worker registering through
1463 /// this notifier advertises (NOI-6, item 4).
1464 ///
1465 /// The composition root wires this from the harness's advertised
1466 /// `AgentSession::capabilities()` so a liminal-connected agent worker's handle
1467 /// carries the primitives its harness supports, which the intervention router
1468 /// gates on. Without this builder the set is empty (observability-only), so a
1469 /// plain activity worker advertises no controls. Pure builder addition, mirroring
1470 /// the registry's capability-carrying registration façade.
1471 #[must_use]
1472 pub fn with_intervention_capabilities(
1473 mut self,
1474 capabilities: aion_core::InterventionCapabilities,
1475 ) -> Self {
1476 self.intervention_capabilities = capabilities;
1477 self
1478 }
1479
1480 /// Bind the connection supervisor the notifier pushes through, immediately
1481 /// after it is constructed with this notifier.
1482 ///
1483 /// Returns `true` when the supervisor was stored, `false` when it was already
1484 /// bound (a second bind is a wiring bug and is ignored, never overwriting the
1485 /// live handle). Call this exactly once, right after
1486 /// [`ConnectionSupervisor::with_services_and_notifier`].
1487 pub fn bind_supervisor(&self, supervisor: ConnectionSupervisor) -> bool {
1488 self.supervisor.set(supervisor).is_ok()
1489 }
1490
1491 /// Snapshot of every live liminal connection the dead-man switch must ping:
1492 /// its pid, the worker it registered, and the push leg to reach it.
1493 ///
1494 /// Built from the SAME guard map that owns each connection's registration,
1495 /// so a connection that has closed (its guard removed) is structurally
1496 /// absent from the snapshot and is never pinged. A poisoned guard map is
1497 /// recovered rather than skipped: silently returning no targets would turn
1498 /// the dead-man switch off exactly when the server is already unhealthy.
1499 #[must_use]
1500 pub fn liveness_targets(&self) -> Vec<super::liminal_liveness::LivenessTarget> {
1501 let Some(supervisor) = self.supervisor.get() else {
1502 return Vec::new();
1503 };
1504 let guards = match self.guards.lock() {
1505 Ok(guards) => guards,
1506 Err(poisoned) => poisoned.into_inner(),
1507 };
1508 guards
1509 .iter()
1510 .filter_map(|(pid, guard)| {
1511 guard
1512 .worker_id()
1513 .map(|worker_id| super::liminal_liveness::LivenessTarget {
1514 pid: *pid,
1515 worker_id,
1516 delivery: LiminalWorkerDelivery::new(supervisor.clone(), *pid),
1517 })
1518 })
1519 .collect()
1520 }
1521
1522 /// Refresh one worker's in-flight task stamp from a [`WorkerLivenessBeat`]
1523 /// published on [`WORKER_LIVENESS_CHANNEL`].
1524 ///
1525 /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1526 /// registration guard this notifier owns), never by wire identity, so a
1527 /// beat can only refresh tasks assigned to the worker that sent it. An
1528 /// untracked task is benign (an outbox dispatch the tracker never held, or
1529 /// a beat racing its completion) and is dropped silently; a malformed
1530 /// payload or unregistered connection is logged and dropped — a bad beat
1531 /// must never tear down the connection callback.
1532 fn record_liveness_beat(&self, pid: u64, payload: &[u8]) {
1533 let Some(tracker) = &self.heartbeat_tracker else {
1534 return;
1535 };
1536 let beat: WorkerLivenessBeat = match serde_json::from_slice(payload) {
1537 Ok(beat) => beat,
1538 Err(error) => {
1539 tracing::warn!(%error, "liveness tap: malformed WorkerLivenessBeat payload");
1540 return;
1541 }
1542 };
1543 let worker_id = match self.guards.lock() {
1544 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1545 Err(poisoned) => poisoned
1546 .into_inner()
1547 .get(&pid)
1548 .and_then(WorkerRegistration::worker_id),
1549 };
1550 let Some(worker_id) = worker_id else {
1551 tracing::warn!(
1552 connection_pid = pid,
1553 "liveness tap: beat from a connection with no registered worker"
1554 );
1555 return;
1556 };
1557 let activity_id = ActivityId::from_sequence_position(beat.ordinal);
1558 if let Err(error) = tracker.record_liveness(
1559 worker_id,
1560 &beat.workflow_id,
1561 &activity_id,
1562 std::time::Instant::now(),
1563 ) {
1564 tracing::error!(
1565 %error,
1566 connection_pid = pid,
1567 "liveness tap: heartbeat tracker refresh failed"
1568 );
1569 }
1570 }
1571
1572 /// Apply one worker's [`WorkerCapabilitiesAnnouncement`] published on
1573 /// [`WORKER_CAPABILITIES_CHANNEL`] to its registered handle.
1574 ///
1575 /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1576 /// registration guard this notifier owns), never by wire identity, so an
1577 /// announcement can only ever update the worker that sent it. A malformed
1578 /// payload or unregistered connection is logged and dropped — a bad
1579 /// announcement must never tear down the connection callback.
1580 fn record_capabilities_announcement(&self, pid: u64, payload: &[u8]) {
1581 let announcement: WorkerCapabilitiesAnnouncement = match serde_json::from_slice(payload) {
1582 Ok(announcement) => announcement,
1583 Err(error) => {
1584 tracing::warn!(
1585 %error,
1586 "capabilities tap: malformed WorkerCapabilitiesAnnouncement payload"
1587 );
1588 return;
1589 }
1590 };
1591 let worker_id = match self.guards.lock() {
1592 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1593 Err(poisoned) => poisoned
1594 .into_inner()
1595 .get(&pid)
1596 .and_then(WorkerRegistration::worker_id),
1597 };
1598 let Some(worker_id) = worker_id else {
1599 tracing::warn!(
1600 connection_pid = pid,
1601 "capabilities tap: announcement from a connection with no registered worker"
1602 );
1603 return;
1604 };
1605 match self
1606 .registry
1607 .set_intervention_capabilities(worker_id, &announcement.capabilities)
1608 {
1609 Ok(true) => {}
1610 Ok(false) => tracing::warn!(
1611 connection_pid = pid,
1612 worker_id = ?worker_id,
1613 "capabilities tap: announcement raced the worker's deregistration"
1614 ),
1615 Err(error) => tracing::error!(
1616 %error,
1617 connection_pid = pid,
1618 "capabilities tap: registry capability update failed"
1619 ),
1620 }
1621 }
1622
1623 fn validate_registration_contract(
1624 &self,
1625 registration: &WireWorkerRegistration,
1626 ) -> Result<(), LiminalServerError> {
1627 let Some(engine) = &self.contract_catalog else {
1628 return Ok(());
1629 };
1630 let advertised =
1631 registration
1632 .activities
1633 .iter()
1634 .map(|activity| {
1635 let input_schema =
1636 serde_json::from_str(&activity.input_schema_json).map_err(|error| {
1637 LiminalServerError::ListenerAccept {
1638 message: format!(
1639 "liminal worker activity `{}` input schema is invalid: {error}",
1640 activity.name
1641 ),
1642 }
1643 })?;
1644 let output_schema = serde_json::from_str(&activity.output_schema_json)
1645 .map_err(|error| LiminalServerError::ListenerAccept {
1646 message: format!(
1647 "liminal worker activity `{}` output schema is invalid: {error}",
1648 activity.name
1649 ),
1650 })?;
1651 Ok(aion_package::ActivityDescriptor {
1652 name: activity.name.clone(),
1653 input_schema,
1654 output_schema,
1655 })
1656 })
1657 .collect::<Result<Vec<_>, LiminalServerError>>()?;
1658 // Both advertised forms travel into the gate together: the NAME set the
1659 // dispatcher selects on and the CONTRACT set admission compares. A
1660 // refusal computed from one while the record printed the other is what
1661 // produced the 2026-07-30 self-contradicting log.
1662 let activity_types = registration
1663 .activity_types
1664 .iter()
1665 .cloned()
1666 .collect::<std::collections::BTreeSet<_>>();
1667 super::contracts::validate_worker_contracts(
1668 engine,
1669 self.registry.admission_audit(),
1670 ®istration.task_queue,
1671 registration.node.as_deref(),
1672 ®istration.identity,
1673 super::contracts::WorkerAdvertisement {
1674 activity_types: &activity_types,
1675 contracts: &advertised,
1676 },
1677 )
1678 .map_err(|error| LiminalServerError::ListenerAccept {
1679 message: error.to_string(),
1680 })
1681 }
1682
1683 /// Admit one connecting worker past the TRANSPORT's own refusals, or refuse
1684 /// it with a structured reason.
1685 ///
1686 /// Split out of [`ConnectionNotifier::on_worker_registered`] so every
1687 /// refusal this transport owns — unbound supervisor, registry error, lease
1688 /// failure — funnels through exactly one place that logs it. Before that
1689 /// split the rejection reason was carried in the `Rejected` ack and logged
1690 /// NOWHERE: on 2026-07-29 a worker whose `.v4` contract field-mismatched was
1691 /// refused on every single dial with the server silent on all of them, and
1692 /// the refused process looked merely asleep.
1693 ///
1694 /// **The contract check is deliberately NOT here.** That 2026-07-29 fix was
1695 /// made in this caller rather than at the shared gate, and within a week the
1696 /// gRPC transport re-manifested the identical silent refusal with nothing in
1697 /// the tree able to notice (#147). The gate now names its own refusal for
1698 /// every transport, so the contract check runs BEFORE this function and its
1699 /// error propagates from here already spoken for.
1700 fn admit_registration(
1701 &self,
1702 pid: u64,
1703 registration: &WireWorkerRegistration,
1704 ) -> Result<(), LiminalServerError> {
1705 // The delivery leg needs the supervisor to push to this connection. In
1706 // correct wiring it is bound before any connection is accepted; a missing
1707 // binding is a rejected registration, never a panic.
1708 let supervisor =
1709 self.supervisor
1710 .get()
1711 .ok_or_else(|| LiminalServerError::ListenerAccept {
1712 message: format!(
1713 "liminal worker registration for connection {pid} rejected: \
1714 notifier supervisor handle not yet bound"
1715 ),
1716 })?;
1717
1718 let delivery = WorkerDelivery::Liminal(LiminalWorkerDelivery::new(supervisor.clone(), pid));
1719 let node = normalize_wire_node(registration.node.as_deref());
1720 // Insert into the SAME registry, selected the SAME way, as a gRPC worker.
1721 // A registry error (poisoned lock) becomes a Rejected ack so the worker
1722 // never believes it is registered when it is not.
1723 let guard = self
1724 .registry
1725 .register_delivery_with_capabilities(
1726 registration.namespaces.iter().cloned(),
1727 registration.task_queue.clone(),
1728 node,
1729 registration.activity_types.iter(),
1730 delivery,
1731 self.intervention_capabilities.clone(),
1732 )
1733 .map_err(|error| LiminalServerError::ListenerAccept {
1734 message: format!(
1735 "liminal worker registration for connection {pid} rejected: {error}"
1736 ),
1737 })?;
1738 let worker_id = guard
1739 .worker_id()
1740 .ok_or_else(|| LiminalServerError::ListenerAccept {
1741 message: format!(
1742 "liminal worker registration for connection {pid} has no worker id"
1743 ),
1744 })?;
1745
1746 // OWN the guard for the connection's lifetime, keyed by pid. Dropping it
1747 // (on unregister) deregisters the worker, so the registration lives
1748 // exactly as long as the connection.
1749 let mut guards = self.guards.lock().map_err(|_| {
1750 // The accepted registry entry cannot be tracked for deregistration, so
1751 // reject (and drop the just-created guard, deregistering it) rather
1752 // than leak a never-deregistered association.
1753 LiminalServerError::ListenerAccept {
1754 message: format!(
1755 "liminal worker registration for connection {pid} rejected: \
1756 notifier guard map poisoned"
1757 ),
1758 }
1759 })?;
1760 guards.insert(pid, guard);
1761 drop(guards);
1762 if let Some(tracker) = &self.heartbeat_tracker {
1763 if let Err(error) = tracker.register_connection(worker_id, std::time::Instant::now()) {
1764 let removed = match self.guards.lock() {
1765 Ok(mut guards) => guards.remove(&pid),
1766 Err(poisoned) => poisoned.into_inner().remove(&pid),
1767 };
1768 drop(removed);
1769 return Err(LiminalServerError::ListenerAccept {
1770 message: format!(
1771 "liminal worker registration for connection {pid} rejected: {error}"
1772 ),
1773 });
1774 }
1775 }
1776 tracing::info!(
1777 connection_pid = pid,
1778 identity = %registration.identity,
1779 task_queue = %registration.task_queue,
1780 "registered liminal worker in-band"
1781 );
1782 Ok(())
1783 }
1784}
1785
1786impl ConnectionNotifier for LiminalConnectionNotifier {
1787 fn on_worker_registered(
1788 &self,
1789 pid: u64,
1790 registration: &WireWorkerRegistration,
1791 ) -> Result<(), LiminalServerError> {
1792 // The contract gate NAMES its own refusal, for every transport, with the
1793 // queue, the node, the worker build identity and the structured reason.
1794 // So this path propagates that refusal SILENTLY rather than restating
1795 // it: a second copy of the same log is how #147 happened, and a log
1796 // written twice per refusal is #94's 12 MB/hour with a second author.
1797 self.validate_registration_contract(registration)?;
1798 // Every refusal this TRANSPORT owns is named here instead, at WARN, with
1799 // the same reason that rides back in the `Rejected` ack, so both sides'
1800 // logs carry identical text and a refused worker can be diagnosed from
1801 // either end.
1802 self.admit_registration(pid, registration)
1803 .inspect_err(|error| {
1804 // The two advertised sets are logged SEPARATELY and named for
1805 // what each one is. Logging `activity_types` alone beside a
1806 // contract-mismatch reason produced a record that listed an
1807 // action as advertised in the same line that reported it
1808 // `<missing>` — true of two different sets, and unresolvable by
1809 // the operator it exists to help.
1810 let advertised_contracts = registration
1811 .activities
1812 .iter()
1813 .map(|activity| activity.name.as_str())
1814 .collect::<Vec<_>>();
1815 tracing::warn!(
1816 connection_pid = pid,
1817 identity = %registration.identity,
1818 task_queue = %registration.task_queue,
1819 namespaces = ?registration.namespaces,
1820 advertised_activity_types = ?registration.activity_types,
1821 advertised_contracts = ?advertised_contracts,
1822 reason = %error,
1823 "REFUSED liminal worker registration"
1824 );
1825 })
1826 }
1827
1828 fn on_worker_unregistered(&self, pid: u64) {
1829 // Remove + drop the guard for pid, deregistering the worker. A poisoned
1830 // lock on the close path has no peer to report to; recover the guard map
1831 // and still drop the guard so the registry does not keep routing to a
1832 // gone connection.
1833 let removed = match self.guards.lock() {
1834 Ok(mut guards) => guards.remove(&pid),
1835 Err(poisoned) => poisoned.into_inner().remove(&pid),
1836 };
1837 let worker_id = removed.as_ref().and_then(WorkerRegistration::worker_id);
1838 if let (Some(tracker), Some(worker_id)) = (&self.heartbeat_tracker, worker_id) {
1839 if let Err(error) = tracker.unregister_connection(worker_id) {
1840 tracing::error!(
1841 %error,
1842 connection_pid = pid,
1843 worker_id = worker_id.value(),
1844 "failed to clear liminal worker connection lease"
1845 );
1846 }
1847 }
1848 if removed.is_some() {
1849 tracing::info!(
1850 connection_pid = pid,
1851 "deregistered liminal worker on disconnect"
1852 );
1853 }
1854 }
1855
1856 fn on_channel_publish(&self, pid: u64, channel: &str, payload: &[u8]) -> bool {
1857 if let Some(tracker) = &self.heartbeat_tracker {
1858 let worker_id = match self.guards.lock() {
1859 Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1860 Err(poisoned) => poisoned
1861 .into_inner()
1862 .get(&pid)
1863 .and_then(WorkerRegistration::worker_id),
1864 };
1865 if let Some(worker_id) = worker_id {
1866 if let Err(error) =
1867 tracker.record_connection_activity(worker_id, std::time::Instant::now())
1868 {
1869 tracing::error!(
1870 %error,
1871 connection_pid = pid,
1872 worker_id = worker_id.value(),
1873 "failed to advance liminal worker connection lease"
1874 );
1875 }
1876 }
1877 }
1878 // Reserved liveness channel: refresh the beat's in-flight task stamp in
1879 // the shared heartbeat tracker (always consumed, never fanned out).
1880 if channel == WORKER_LIVENESS_CHANNEL {
1881 self.record_liveness_beat(pid, payload);
1882 return true;
1883 }
1884 // Reserved capabilities channel: apply the worker's advertised
1885 // intervention capabilities to its registered handle (always consumed).
1886 if channel == WORKER_CAPABILITIES_CHANNEL {
1887 self.record_capabilities_announcement(pid, payload);
1888 return true;
1889 }
1890 // Only consume the reserved observability channel; any other channel falls
1891 // through to liminal's normal fan-out (this returns false).
1892 if channel != liminal_sdk::OBSERVABILITY_CHANNEL {
1893 return false;
1894 }
1895 let Some(tap) = &self.transcript else {
1896 // No transcript sequencer installed (a non-agent deployment): still
1897 // CONSUME the reserved channel so it never leaks into the fan-out, but
1898 // drop the event — there is nothing to persist it into.
1899 return true;
1900 };
1901 let event: aion_core::ActivityEvent = match serde_json::from_slice(payload) {
1902 Ok(event) => event,
1903 Err(error) => {
1904 tracing::warn!(%error, "observability tap: malformed ActivityEvent payload");
1905 return true;
1906 }
1907 };
1908 // Hand the event to the ordered drain queue (the synchronous
1909 // connection-process callback never blocks). A full queue means the
1910 // durable append cannot keep up: the event is dropped with a warning,
1911 // never buffered without bound.
1912 if let Err(error) = tap.queue.try_send(event) {
1913 tracing::warn!(%error, "observability tap: transcript queue rejected event");
1914 }
1915 true
1916 }
1917}
1918
1919/// The production [`InterventionTransport`](super::intervention::InterventionTransport):
1920/// pushes a routed command to the owning worker over its liminal server-push
1921/// connection (NOI-6, §6.2).
1922///
1923/// It reads the worker handle's [`WorkerDelivery::Liminal`] leg and pushes the
1924/// neutral [`InterventionRequest`] via [`LiminalWorkerDelivery::push_intervention`],
1925/// running the blocking push off the async runtime. A worker delivered over gRPC
1926/// (no liminal leg) surfaces the stale-target no-op via a connection-lost error, so
1927/// the router NACKs the operator rather than routing to a leg it cannot reach.
1928#[derive(Clone, Debug, Default)]
1929pub struct LiminalInterventionTransport;
1930
1931#[async_trait]
1932impl super::intervention::InterventionTransport for LiminalInterventionTransport {
1933 async fn push(
1934 &self,
1935 worker: &super::registry::WorkerHandle,
1936 command: aion_core::InterventionCommand,
1937 ) -> Result<aion_core::InterventionOutcome, ServerError> {
1938 let delivery = match worker.delivery() {
1939 WorkerDelivery::Liminal(delivery) => delivery.clone(),
1940 WorkerDelivery::Grpc(_) => {
1941 // No liminal leg to push to: the intervention transport rides the
1942 // liminal push channel only, so this is unreachable for the target.
1943 return Err(ServerError::worker_connection_lost(
1944 "liminal-push",
1945 "owning worker is not delivered over liminal".to_owned(),
1946 ));
1947 }
1948 };
1949 let request = InterventionRequest {
1950 intervention: command,
1951 };
1952 let reply = tokio::task::spawn_blocking(move || delivery.push_intervention(&request))
1953 .await
1954 .map_err(|error| {
1955 dispatch_error(
1956 "liminal-push",
1957 format!("intervention task join failed: {error}"),
1958 )
1959 })??;
1960 Ok(reply.outcome)
1961 }
1962}
1963
1964#[cfg(test)]
1965#[path = "liminal_contract_tests.rs"]
1966mod contract_tests;
1967
1968#[cfg(test)]
1969mod tests {
1970 use super::{channel_for_row, dispatch_channel_name, normalize_wire_node};
1971 use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
1972 use aion_store::{OutboxRow, OutboxStatus};
1973 use chrono::Utc;
1974 use uuid::Uuid;
1975
1976 /// The NOI-6 dispatch owner guard RELEASES its binding on drop, on EVERY exit path
1977 /// (reply, error, panic) — so the attempt-owner back-index tracks exactly the
1978 /// attempts currently in flight. This is the invariant the dispatch path relies on
1979 /// to never leak a finished attempt's owner.
1980 #[tokio::test]
1981 async fn attempt_owner_guard_releases_on_drop() -> Result<(), Box<dyn std::error::Error>> {
1982 use super::super::intervention::{AttemptKey, AttemptOwnerIndex};
1983 use super::super::registry::{ConnectedWorkerRegistry, WorkerDelivery};
1984 use super::AttemptOwnerGuard;
1985
1986 // A real registration yields a real WorkerId (there is no fabricated id).
1987 let registry = ConnectedWorkerRegistry::default();
1988 let (tx, _rx) = tokio::sync::mpsc::channel(1);
1989 let types = [String::from("agent")];
1990 let registration = registry.register_delivery_with_capabilities(
1991 [String::from("default")],
1992 String::from("default"),
1993 None,
1994 types.iter(),
1995 WorkerDelivery::Grpc(tx),
1996 aion_core::InterventionCapabilities::none(),
1997 )?;
1998 let worker = registration
1999 .worker_id()
2000 .ok_or("registration must assign a worker id")?;
2001
2002 let owners = AttemptOwnerIndex::new();
2003 let key = AttemptKey::new(
2004 WorkflowId::new(Uuid::nil()),
2005 aion_core::RunId::new(Uuid::from_u128(0x11)),
2006 ActivityId::from_sequence_position(3),
2007 1,
2008 );
2009 owners.bind(key.clone(), worker);
2010 assert_eq!(
2011 owners.owner(&key),
2012 Some(worker),
2013 "owner bound before the guard"
2014 );
2015 {
2016 let _guard = AttemptOwnerGuard {
2017 owners: owners.clone(),
2018 key: key.clone(),
2019 };
2020 assert_eq!(
2021 owners.owner(&key),
2022 Some(worker),
2023 "still bound while in flight"
2024 );
2025 }
2026 // The guard dropped at the end of the block: the binding is released, so a
2027 // later intervention resolves no owner (the too-late no-op).
2028 assert_eq!(
2029 owners.owner(&key),
2030 None,
2031 "owner released when the dispatch returns"
2032 );
2033 Ok(())
2034 }
2035
2036 /// The channel format is pinned EXACTLY: any change is a wire-compatibility
2037 /// break (the dispatcher and any worker subscription must agree byte-for-byte).
2038 /// The UNPINNED (`None`) channel MUST stay byte-identical to the pre-NODE-5
2039 /// format so existing pool subscriptions are stable.
2040 #[test]
2041 fn channel_format_is_pinned() {
2042 assert_eq!(
2043 dispatch_channel_name("remote", "gpu", None),
2044 "aion.dispatch.remote.gpu"
2045 );
2046 assert_eq!(
2047 dispatch_channel_name("local", "norn", None),
2048 "aion.dispatch.local.norn"
2049 );
2050 }
2051
2052 /// A node-pinned dispatch appends the node as an injectively-encoded
2053 /// sub-segment: `f(ns, tq, Some(node))` == `aion.dispatch.{ns}.{tq}.{node}`.
2054 #[test]
2055 fn node_pinned_channel_appends_node_subsegment() {
2056 assert_eq!(
2057 dispatch_channel_name("remote", "gpu", Some("box-7")),
2058 "aion.dispatch.remote.gpu.box-7"
2059 );
2060 }
2061
2062 /// Same input always yields the same channel (the function is stable/total),
2063 /// for both the unpinned and node-pinned cases.
2064 #[test]
2065 fn channel_derivation_is_stable() {
2066 assert_eq!(
2067 dispatch_channel_name("default", "default", None),
2068 dispatch_channel_name("default", "default", None)
2069 );
2070 assert_eq!(
2071 dispatch_channel_name("default", "default", Some("box-1")),
2072 dispatch_channel_name("default", "default", Some("box-1"))
2073 );
2074 }
2075
2076 /// Distinct `(namespace, task_queue)` pools derive distinct channels — the
2077 /// whole point of NSTQ-5: `(remote, gpu)` and `(local, norn)` never collide.
2078 #[test]
2079 fn distinct_pools_get_distinct_channels() {
2080 assert_ne!(
2081 dispatch_channel_name("remote", "gpu", None),
2082 dispatch_channel_name("local", "norn", None)
2083 );
2084 }
2085
2086 /// A node-pinned dispatch and the unpinned dispatch for the SAME pool derive
2087 /// DISTINCT channels, and two distinct nodes for the same pool also differ —
2088 /// the property node isolation rests on (the subscriber contract).
2089 #[test]
2090 fn node_pin_separates_channels() {
2091 let unpinned = dispatch_channel_name("remote", "gpu", None);
2092 let box7 = dispatch_channel_name("remote", "gpu", Some("box-7"));
2093 let box8 = dispatch_channel_name("remote", "gpu", Some("box-8"));
2094 assert_ne!(
2095 unpinned, box7,
2096 "pinned dispatch must not reach unpinned pool"
2097 );
2098 assert_ne!(box7, box8, "distinct nodes must not collide");
2099 }
2100
2101 /// The core injectivity property: free-form fields containing the segment
2102 /// separator `.` must NOT bleed across the join. With the raw `format!` the
2103 /// disjoint pools `("a.b", "c")` and `("a", "b.c")` both collapsed onto
2104 /// `aion.dispatch.a.b.c` — a cross-pool/cross-namespace leak. The per-segment
2105 /// encode keeps them distinct.
2106 #[test]
2107 fn dotted_fields_do_not_collide_across_segments() {
2108 assert_ne!(
2109 dispatch_channel_name("a.b", "c", None),
2110 dispatch_channel_name("a", "b.c", None),
2111 "a '.' in a field must not bleed across the segment separator"
2112 );
2113 }
2114
2115 /// Injectivity holds ACROSS segment counts: a 2-segment (unpinned) channel
2116 /// can never be confused with a 3-segment (node-pinned) channel even when a
2117 /// `.` in a field would otherwise make the raw strings line up. Both
2118 /// directions of the brief's collision cases must stay distinct.
2119 #[test]
2120 fn node_subsegment_does_not_collide_with_dotted_fields() {
2121 // A node sub-segment vs the same dot living inside task_queue.
2122 assert_ne!(
2123 dispatch_channel_name("a", "b", Some("c")),
2124 dispatch_channel_name("a", "b.c", None),
2125 "a node sub-segment must not collide with a dotted task_queue"
2126 );
2127 // The dot living inside namespace vs a node sub-segment.
2128 assert_ne!(
2129 dispatch_channel_name("a.b", "c", None),
2130 dispatch_channel_name("a", "b", Some("c")),
2131 "a dotted namespace must not collide with a node-pinned channel"
2132 );
2133 }
2134
2135 /// More reserved-char shifts that the raw `format!` collapsed but the encode
2136 /// must keep distinct — the dot can sit on either side of the boundary.
2137 #[test]
2138 fn reserved_char_shifts_stay_distinct() {
2139 // Dot at the end of namespace vs start of task_queue.
2140 assert_ne!(
2141 dispatch_channel_name("ns.", "tq", None),
2142 dispatch_channel_name("ns", ".tq", None)
2143 );
2144 // Empty field vs the dot living in the other field.
2145 assert_ne!(
2146 dispatch_channel_name("", "a.b", None),
2147 dispatch_channel_name(".a", "b", None)
2148 );
2149 // The escape char itself must not let a literal `%2E` impersonate an
2150 // encoded `.`: `("%2E", "x")` (literal percent-two-E) must differ from
2151 // `(".", "x")` (an actual dot, which encodes to `%2E`).
2152 assert_ne!(
2153 dispatch_channel_name("%2E", "x", None),
2154 dispatch_channel_name(".", "x", None)
2155 );
2156 }
2157
2158 /// Encoding is injective in ALL THREE segments independently and is exactly
2159 /// reversible (the property the channel relies on), so a small exhaustive
2160 /// sweep of reserved-char arrangements — INCLUDING the optional node taking
2161 /// `None` and every reserved-char value — yields all-distinct channels. This
2162 /// covers cross-segment-count collisions (the `None` vs `Some` boundary) too.
2163 #[test]
2164 fn encoding_is_injective_over_reserved_char_triples() {
2165 let fields = ["a", "a.b", "a.", ".a", ".", "", "%", "%2E", "a%b", "%2."];
2166 let nodes = [
2167 None,
2168 Some("a"),
2169 Some("a.b"),
2170 Some("."),
2171 Some(""),
2172 Some("%2E"),
2173 ];
2174 let mut channels = std::collections::HashSet::new();
2175 for ns in fields {
2176 for tq in fields {
2177 for node in nodes {
2178 let channel = dispatch_channel_name(ns, tq, node);
2179 assert!(
2180 channels.insert(channel.clone()),
2181 "collision on ({ns:?}, {tq:?}, {node:?}) -> {channel}"
2182 );
2183 }
2184 }
2185 }
2186 }
2187
2188 fn row(namespace: &str, task_queue: &str) -> OutboxRow {
2189 let workflow_id = WorkflowId::new(Uuid::new_v4());
2190 OutboxRow {
2191 dispatch_key: format!("{workflow_id}:0"),
2192 workflow_id,
2193 ordinal: 0,
2194 run_id: Some(aion_core::RunId::new_v4()),
2195 namespace: namespace.to_owned(),
2196 task_queue: task_queue.to_owned(),
2197 node: None,
2198 activity_type: "charge-card".to_owned(),
2199 input: Payload::new(ContentType::Json, Vec::new()),
2200 status: OutboxStatus::Pending,
2201 attempt: 0,
2202 visible_after: Utc::now(),
2203 claimed_at: None,
2204 failure_delivered: false,
2205 }
2206 }
2207
2208 /// A row's channel is derived from its durable `(namespace, task_queue)`
2209 /// columns (NSTQ-2), through the same single derivation function — and
2210 /// `activity_type` does NOT enter the channel. With `node = None` the channel
2211 /// is byte-identical to the pre-NODE-5 2-segment form.
2212 #[test]
2213 fn channel_for_row_uses_namespace_and_task_queue_only() {
2214 let remote_gpu = row("remote", "gpu");
2215 let local_norn = row("local", "norn");
2216 assert_eq!(channel_for_row(&remote_gpu), "aion.dispatch.remote.gpu");
2217 assert_eq!(channel_for_row(&local_norn), "aion.dispatch.local.norn");
2218 assert_ne!(channel_for_row(&remote_gpu), channel_for_row(&local_norn));
2219
2220 // Two rows that differ ONLY in activity_type derive the SAME channel:
2221 // activity_type is matched after delivery, not used to select the pool.
2222 let mut other_activity = row("remote", "gpu");
2223 other_activity.activity_type = "refund".to_owned();
2224 assert_eq!(
2225 channel_for_row(&remote_gpu),
2226 channel_for_row(&other_activity),
2227 "activity_type must not affect the channel"
2228 );
2229 }
2230
2231 /// A row carrying `Some(node)` (NODE-2) derives the node-pinned sub-channel,
2232 /// distinct from the same pool's unpinned channel; a row with `None` derives
2233 /// the 2-segment channel. `channel_for_row` threads `row.node` through the
2234 /// single derivation function.
2235 #[test]
2236 fn channel_for_row_derives_node_subchannel_when_pinned() {
2237 let mut pinned = row("remote", "gpu");
2238 pinned.node = Some("box-7".to_owned());
2239 assert_eq!(channel_for_row(&pinned), "aion.dispatch.remote.gpu.box-7");
2240
2241 let unpinned = row("remote", "gpu");
2242 assert_eq!(channel_for_row(&unpinned), "aion.dispatch.remote.gpu");
2243 assert_ne!(channel_for_row(&pinned), channel_for_row(&unpinned));
2244 }
2245
2246 /// The outbox wire request stamps the row's stored ZERO-based attempt as a
2247 /// ONE-based delivery attempt (zero is malformed on the wire) — the exact
2248 /// stamp the gRPC outbox arm's `to_scheduled` applies — carries no labels,
2249 /// and assigns no liveness window (outbox rows are not tracker-tracked; the
2250 /// outbox retry loop is their liveness backstop).
2251 #[test]
2252 fn request_for_row_stamps_one_based_attempt_and_no_window() -> Result<(), crate::ServerError> {
2253 let mut retried = row("remote", "gpu");
2254 retried.attempt = 2;
2255 let run_id = retried
2256 .run_id
2257 .as_ref()
2258 .ok_or_else(|| crate::ServerError::worker_dispatch("", "", "test row missing run"))?;
2259 let token = super::CompletionToken::for_test();
2260 let request = super::request_for_row(&retried, run_id, &token);
2261 assert_eq!(
2262 request.attempt, 3,
2263 "zero-based row attempt goes one-based on the wire"
2264 );
2265 assert!(request.labels.is_empty());
2266 assert_eq!(request.heartbeat_window_ms, 0);
2267
2268 let fresh = row("remote", "gpu");
2269 let fresh_run = fresh
2270 .run_id
2271 .as_ref()
2272 .ok_or_else(|| crate::ServerError::worker_dispatch("", "", "test row missing run"))?;
2273 assert_eq!(super::request_for_row(&fresh, fresh_run, &token).attempt, 1);
2274 Ok(())
2275 }
2276
2277 /// The wire `node` is normalized onto the registry's optional affinity with
2278 /// the SAME none-convention the gRPC registration path uses: `None` and the
2279 /// empty-string node both collapse to unpinned (`None`), a non-empty value is
2280 /// the advertised node. An empty-string node must NOT register a distinct
2281 /// empty affinity no pinned dispatch could match.
2282 #[test]
2283 fn wire_node_normalizes_empty_to_none() {
2284 assert_eq!(normalize_wire_node(None), None);
2285 assert_eq!(normalize_wire_node(Some("")), None);
2286 assert_eq!(normalize_wire_node(Some("box-7")), Some("box-7".to_owned()));
2287 }
2288
2289 // --- #163: the Prefer two-tier spill on the LIMINAL selection path ---------
2290 //
2291 // These exercise `RegistryLiminalDispatch::select_liminal_worker` — the
2292 // liminal transport's worker selection — proving it consults the SAME shared
2293 // `preferred_node_order` two-tier spill the gRPC path uses (the cross-node
2294 // demo behaviour), and that placement NEVER mutates the recorded row's node.
2295 // Selection is delivery-agnostic (`select_worker` filters by node regardless
2296 // of transport), so a worker registered with any delivery drives the same
2297 // selection the production liminal-delivered worker would; the tests assert on
2298 // the SELECTED handle's node, which is exactly what #163 changed.
2299 mod placement_selection {
2300 use std::collections::BTreeSet;
2301 use std::sync::Arc;
2302 use std::time::Duration;
2303
2304 use aion_core::{ActivityId, Payload, RunId, WorkflowId};
2305 use aion_store::{
2306 InMemoryStore, NamespaceOrigin, NamespacePlacement, NamespaceStore, OutboxRow,
2307 };
2308
2309 use crate::error::ServerError;
2310 use crate::worker::bridge::OutboxDeliveryCallback;
2311 use crate::worker::registry::{ConnectedWorkerRegistry, WorkerMessage, WorkerRegistration};
2312 use crate::worker::{DeliveryGate, PlacementCache};
2313
2314 use super::super::RegistryLiminalDispatch;
2315
2316 /// No-op delivery callback: the selection tests never deliver a result, so
2317 /// the completion sink is never invoked. Both methods are unreachable in
2318 /// these tests and simply report "no live run" if ever called.
2319 struct NoopCallback;
2320
2321 impl OutboxDeliveryCallback for NoopCallback {
2322 fn deliver_completion(
2323 &self,
2324 _workflow_id: &WorkflowId,
2325 _activity_id: &ActivityId,
2326 _run_id: Option<&RunId>,
2327 _result: String,
2328 ) -> Result<bool, ServerError> {
2329 Ok(false)
2330 }
2331 fn deliver_failure(
2332 &self,
2333 _workflow_id: &WorkflowId,
2334 _activity_id: &ActivityId,
2335 _run_id: Option<&RunId>,
2336 _reason: String,
2337 ) -> Result<bool, ServerError> {
2338 Ok(false)
2339 }
2340 }
2341
2342 fn labels(values: &[&str]) -> BTreeSet<String> {
2343 values.iter().map(|v| (*v).to_owned()).collect()
2344 }
2345
2346 /// Register a worker advertising `node` for `charge` in `namespace`,
2347 /// returning the registration guard (held to keep it connected).
2348 fn register_node_worker(
2349 registry: &ConnectedWorkerRegistry,
2350 namespace: &str,
2351 node: &str,
2352 ) -> Result<WorkerRegistration, ServerError> {
2353 let (tx, _rx) = tokio::sync::mpsc::channel::<WorkerMessage>(1);
2354 let types = [String::from("charge")];
2355 registry.register_namespaces(
2356 [namespace.to_owned()],
2357 String::from("default"),
2358 Some(node.to_owned()),
2359 types.iter(),
2360 tx,
2361 )
2362 }
2363
2364 /// Register a worker carrying NO node label (`node == None`) — the
2365 /// any-node worker a `Prefer` tier spills to and a `Pinned` tier never
2366 /// admits. The distinction is the whole subject of the refusal tests
2367 /// below.
2368 fn register_unlabelled_worker(
2369 registry: &ConnectedWorkerRegistry,
2370 namespace: &str,
2371 ) -> Result<WorkerRegistration, ServerError> {
2372 let (tx, _rx) = tokio::sync::mpsc::channel::<WorkerMessage>(1);
2373 let types = [String::from("charge")];
2374 registry.register_namespaces(
2375 [namespace.to_owned()],
2376 String::from("default"),
2377 None,
2378 types.iter(),
2379 tx,
2380 )
2381 }
2382
2383 /// Publish `worker` as dispatch-ineligible, exactly as a liveness round
2384 /// would.
2385 fn exclude(
2386 registry: &ConnectedWorkerRegistry,
2387 worker: &WorkerRegistration,
2388 ) -> Result<(), Box<dyn std::error::Error>> {
2389 let worker_id = worker
2390 .worker_id()
2391 .ok_or("registration assigned no worker id")?;
2392 registry.set_dispatch_ineligible([worker_id].into_iter().collect())?;
2393 Ok(())
2394 }
2395
2396 /// Drive the REAL dispatch path and hand back the refusal's reason.
2397 ///
2398 /// Through `OutboxRowDispatch::dispatch`, not through the reason
2399 /// function directly: the reason is a STRING an operator reads, and a
2400 /// test that called the private helper would leave the wiring between
2401 /// selection and refusal — the exact seam that got the tiers wrong —
2402 /// uncovered.
2403 async fn refusal_reason(
2404 dispatch: &RegistryLiminalDispatch,
2405 row: &OutboxRow,
2406 ) -> Result<String, Box<dyn std::error::Error>> {
2407 match super::super::OutboxRowDispatch::dispatch(dispatch, row).await {
2408 Ok(()) => Err("the dispatch must be refused: no worker is selectable".into()),
2409 Err(ServerError::WorkerDispatch { reason, .. }) => Ok(reason),
2410 Err(other) => {
2411 Err(format!("expected a worker-dispatch refusal, got: {other}").into())
2412 }
2413 }
2414 }
2415
2416 /// Build an UNPINNED outbox row (`node == None`) in `namespace` for `charge`.
2417 fn unpinned_row(namespace: &str) -> OutboxRow {
2418 OutboxRow::pending(
2419 WorkflowId::new_v4(),
2420 0,
2421 String::from("charge"),
2422 Payload::from_json(&serde_json::json!({}))
2423 .unwrap_or_else(|_| Payload::new(aion_core::ContentType::Json, Vec::new())),
2424 chrono::Utc::now(),
2425 )
2426 .with_namespace(namespace)
2427 .with_task_queue("default")
2428 }
2429
2430 /// A namespace store with `namespace` set to `Prefer{nodes}`.
2431 async fn prefer_store(
2432 namespace: &str,
2433 nodes: &[&str],
2434 ) -> Result<Arc<dyn NamespaceStore>, ServerError> {
2435 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
2436 store
2437 .register_namespace(namespace, NamespaceOrigin::Explicit)
2438 .await?;
2439 store
2440 .set_namespace_placement(
2441 namespace,
2442 NamespacePlacement::Prefer {
2443 nodes: labels(nodes),
2444 },
2445 )
2446 .await?;
2447 Ok(store)
2448 }
2449
2450 /// A namespace store with `namespace` set to `Pinned{nodes}` (P2-I1).
2451 async fn pinned_store(
2452 namespace: &str,
2453 nodes: &[&str],
2454 ) -> Result<Arc<dyn NamespaceStore>, ServerError> {
2455 let store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
2456 store
2457 .register_namespace(namespace, NamespaceOrigin::Explicit)
2458 .await?;
2459 store
2460 .set_namespace_placement(
2461 namespace,
2462 NamespacePlacement::Pinned {
2463 nodes: labels(nodes),
2464 },
2465 )
2466 .await?;
2467 Ok(store)
2468 }
2469
2470 /// Build a `RegistryLiminalDispatch` over `registry` whose placement cache
2471 /// reads `ns_store` (zero TTL so each selection sees the latest placement).
2472 fn liminal_dispatch(
2473 registry: &ConnectedWorkerRegistry,
2474 ns_store: Arc<dyn NamespaceStore>,
2475 ) -> RegistryLiminalDispatch {
2476 let cache = PlacementCache::new(ns_store, Duration::ZERO);
2477 RegistryLiminalDispatch::new(
2478 registry.clone(),
2479 Arc::new(NoopCallback),
2480 DeliveryGate::default(),
2481 )
2482 .with_placement_cache(cache)
2483 }
2484
2485 /// R3 (#197): a pool whose only worker is eligibility-excluded says SO,
2486 /// and does not report itself empty.
2487 ///
2488 /// The remedies differ and are close to opposite: an empty pool needs a
2489 /// worker started, an excluded pool needs its liveness verdict read —
2490 /// and starting a second worker there changes nothing, because the new
2491 /// one serves the same probation.
2492 #[tokio::test]
2493 async fn an_excluded_pool_refusal_names_the_exclusion()
2494 -> Result<(), Box<dyn std::error::Error>> {
2495 let registry = ConnectedWorkerRegistry::default();
2496 let worker = register_unlabelled_worker(®istry, "t")?;
2497 exclude(®istry, &worker)?;
2498 let dispatch = RegistryLiminalDispatch::new(
2499 registry.clone(),
2500 Arc::new(NoopCallback),
2501 DeliveryGate::default(),
2502 );
2503
2504 let reason = refusal_reason(&dispatch, &unpinned_row("t")).await?;
2505 assert!(
2506 reason.contains("NONE is currently dispatch-eligible"),
2507 "the refusal must name the exclusion it found; said: {reason}"
2508 );
2509 assert!(
2510 !reason.contains("no liminal worker registered"),
2511 "a pool holding a registered worker is not an empty pool; said: {reason}"
2512 );
2513 Ok(())
2514 }
2515
2516 /// THE CONTROL for the test above: a genuinely empty pool still reports
2517 /// itself empty.
2518 ///
2519 /// Without it a refusal hard-coded to the exclusion sentence would
2520 /// satisfy that test and misdiagnose every truly unserved queue,
2521 /// telling an operator not to start the worker they need.
2522 #[tokio::test]
2523 async fn an_empty_pool_refusal_says_nobody_is_registered()
2524 -> Result<(), Box<dyn std::error::Error>> {
2525 let registry = ConnectedWorkerRegistry::default();
2526 let dispatch = RegistryLiminalDispatch::new(
2527 registry.clone(),
2528 Arc::new(NoopCallback),
2529 DeliveryGate::default(),
2530 );
2531
2532 let reason = refusal_reason(&dispatch, &unpinned_row("t")).await?;
2533 assert!(
2534 reason.contains("no liminal worker registered for the row\'s pool"),
2535 "an empty pool must say nobody is registered; said: {reason}"
2536 );
2537 assert!(
2538 !reason.contains("starting another worker will not help"),
2539 "starting a worker is EXACTLY the remedy for an empty pool; said: {reason}"
2540 );
2541 Ok(())
2542 }
2543
2544 /// 🔴 F3: a `Pinned{{n1}}` namespace with no n1-labelled worker must not
2545 /// be blamed on an ineligible UNLABELLED worker.
2546 ///
2547 /// Selection walked `Required{{n1}}` — the unlabelled worker was never a
2548 /// candidate, because a hard pin has no `None` spill. Counting the
2549 /// exclusion over the row\'s own node instead (`None`, which matches
2550 /// EVERY worker in the pool) makes the refusal say "starting another
2551 /// worker will not help" when starting an n1-labelled worker is the one
2552 /// thing that would.
2553 ///
2554 /// A refusal that sends an operator away from the only remedy is worse
2555 /// than the catch-all it replaced, so the count must be taken over the
2556 /// SAME tier sequence selection actually walked.
2557 #[tokio::test]
2558 async fn a_pinned_namespace_miss_is_not_blamed_on_an_ineligible_unlabelled_worker()
2559 -> Result<(), Box<dyn std::error::Error>> {
2560 let ns_store = pinned_store("t", &["n1"]).await?;
2561 let registry = ConnectedWorkerRegistry::default();
2562 // The only worker in the pool: unlabelled, and excluded. No
2563 // n1-labelled worker exists at all.
2564 let worker = register_unlabelled_worker(®istry, "t")?;
2565 exclude(®istry, &worker)?;
2566 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2567
2568 let reason = refusal_reason(&dispatch, &unpinned_row("t")).await?;
2569 assert!(
2570 !reason.contains("starting another worker will not help"),
2571 "the excluded worker was never admissible for a hard pin, so it cannot be the \
2572 reason this row found nobody — and an n1-labelled worker IS the remedy this \
2573 refusal just told the operator not to try. Said: {reason}"
2574 );
2575 assert!(
2576 reason.contains("no liminal worker registered for the row\'s pool"),
2577 "no worker admissible to the pin is registered, and that is what the refusal \
2578 must say; said: {reason}"
2579 );
2580 Ok(())
2581 }
2582
2583 /// THE CONTROL for the pin test: with an n1-labelled worker present and
2584 /// excluded, the SAME namespace does report the exclusion.
2585 ///
2586 /// Without it, a count that always returned zero under a pin would
2587 /// satisfy the test above and silently retire the whole R3 distinction
2588 /// for every pinned namespace.
2589 #[tokio::test]
2590 async fn a_pinned_namespace_does_report_an_excluded_admissible_worker()
2591 -> Result<(), Box<dyn std::error::Error>> {
2592 let ns_store = pinned_store("t", &["n1"]).await?;
2593 let registry = ConnectedWorkerRegistry::default();
2594 let worker = register_node_worker(®istry, "t", "n1")?;
2595 exclude(®istry, &worker)?;
2596 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2597
2598 let reason = refusal_reason(&dispatch, &unpinned_row("t")).await?;
2599 assert!(
2600 reason.contains("NONE is currently dispatch-eligible"),
2601 "an n1-labelled worker IS admissible to Pinned{{n1}}, so its exclusion is exactly \
2602 why this row found nobody; said: {reason}"
2603 );
2604 Ok(())
2605 }
2606
2607 /// #163 (prefer): an unpinned row in a `Prefer{n1}` namespace selects the
2608 /// n1 worker on the liminal path when one is live, even with an n2 worker
2609 /// also connected.
2610 #[tokio::test]
2611 async fn prefer_selects_preferred_node_worker_on_liminal_path()
2612 -> Result<(), Box<dyn std::error::Error>> {
2613 let ns_store = prefer_store("t", &["n1"]).await?;
2614 let registry = ConnectedWorkerRegistry::default();
2615 let _n1 = register_node_worker(®istry, "t", "n1")?;
2616 let _n2 = register_node_worker(®istry, "t", "n2")?;
2617 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2618
2619 let row = unpinned_row("t");
2620 let selected = dispatch
2621 .select_liminal_worker(&row)
2622 .await?
2623 .worker
2624 .ok_or("a worker must be selected")?;
2625 assert_eq!(
2626 selected.node(),
2627 Some("n1"),
2628 "the liminal path prefers the n1 worker while it is live"
2629 );
2630 // Determinism gate: preference never mutates the recorded row's node.
2631 assert_eq!(row.node, None, "placement must never mutate the row's node");
2632 Ok(())
2633 }
2634
2635 /// #163 (spill): an unpinned row in a `Prefer{n1}` namespace SPILLS to the
2636 /// only live worker (n2) on the liminal path when no n1 worker is
2637 /// connected — the cross-node node-loss failover behaviour.
2638 #[tokio::test]
2639 async fn prefer_spills_to_any_live_worker_on_liminal_path()
2640 -> Result<(), Box<dyn std::error::Error>> {
2641 let ns_store = prefer_store("t", &["n1"]).await?;
2642 // Only an n2 worker is live: no n1-labelled worker exists at all.
2643 let registry = ConnectedWorkerRegistry::default();
2644 let _n2 = register_node_worker(®istry, "t", "n2")?;
2645 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2646
2647 let row = unpinned_row("t");
2648 let selected = dispatch
2649 .select_liminal_worker(&row)
2650 .await?
2651 .worker
2652 .ok_or("the spill must select the live n2 worker")?;
2653 assert_eq!(
2654 selected.node(),
2655 Some("n2"),
2656 "with no n1 worker live, the liminal selection spills to the live n2 worker"
2657 );
2658 assert_eq!(row.node, None, "spill must never mutate the row's node");
2659 Ok(())
2660 }
2661
2662 /// #163 (determinism, mirrors the gRPC `placement_never_mutates_recorded_row_node`
2663 /// test): under `Prefer{n1}` the SAME unpinned row selected once to the n1
2664 /// worker and once (after n1 leaves) spilled to n2 keeps `node == None`
2665 /// BOTH times — selection reads the row's node, never the placement, so
2666 /// replay sees an identical command stream irrespective of the target.
2667 #[tokio::test]
2668 async fn placement_never_mutates_recorded_row_node_on_liminal_path()
2669 -> Result<(), Box<dyn std::error::Error>> {
2670 let ns_store = prefer_store("t", &["n1"]).await?;
2671 let registry = ConnectedWorkerRegistry::default();
2672 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2673
2674 // Routing A: n1 present -> preferred selection.
2675 let n1 = register_node_worker(®istry, "t", "n1")?;
2676 let row_a = unpinned_row("t");
2677 let selected_a = dispatch
2678 .select_liminal_worker(&row_a)
2679 .await?
2680 .worker
2681 .ok_or("routing A must select a worker")?;
2682 assert_eq!(selected_a.node(), Some("n1"));
2683
2684 // n1 leaves; only n2 remains.
2685 n1.deregister()?;
2686 let _n2 = register_node_worker(®istry, "t", "n2")?;
2687
2688 // Routing B: same shape of unpinned row -> spills to n2.
2689 let row_b = unpinned_row("t");
2690 let selected_b = dispatch
2691 .select_liminal_worker(&row_b)
2692 .await?
2693 .worker
2694 .ok_or("routing B must spill to a worker")?;
2695 assert_eq!(selected_b.node(), Some("n2"));
2696
2697 // The recorded row node is None in BOTH routings: the dispatch target
2698 // (n1 vs n2) did not perturb it.
2699 assert_eq!(row_a.node, None);
2700 assert_eq!(row_b.node, None);
2701 assert_eq!(
2702 row_a.node, row_b.node,
2703 "the recorded row node is identical regardless of which worker was selected"
2704 );
2705 Ok(())
2706 }
2707
2708 /// #164 (P2-I1 hard pin): an unpinned row in a `Pinned{n1}` namespace
2709 /// selects the n1 worker on the liminal path when live — exactly like
2710 /// Prefer's happy path.
2711 #[tokio::test]
2712 async fn pinned_selects_required_node_worker_on_liminal_path()
2713 -> Result<(), Box<dyn std::error::Error>> {
2714 let ns_store = pinned_store("t", &["n1"]).await?;
2715 let registry = ConnectedWorkerRegistry::default();
2716 let _n1 = register_node_worker(®istry, "t", "n1")?;
2717 let _n2 = register_node_worker(®istry, "t", "n2")?;
2718 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2719
2720 let row = unpinned_row("t");
2721 let selected = dispatch
2722 .select_liminal_worker(&row)
2723 .await?
2724 .worker
2725 .ok_or("the required n1 worker must be selected")?;
2726 assert_eq!(selected.node(), Some("n1"));
2727 assert_eq!(row.node, None, "placement must never mutate the row's node");
2728 Ok(())
2729 }
2730
2731 /// #164 (P2-I1 no spill — the load-bearing test): an unpinned row in a
2732 /// `Pinned{n1}` namespace with ONLY a live n2 worker selects NOTHING — it
2733 /// must NEVER spill to the wrong-node worker. This is the exact opposite of
2734 /// the `prefer_spills_to_any_live_worker_on_liminal_path` behaviour and would
2735 /// FAIL under the old fall-through (which selected any worker for Pinned).
2736 /// The `Ok(None)` drives the outbox no-worker retry/stall, mirroring the
2737 /// gRPC wait.
2738 #[tokio::test]
2739 async fn pinned_never_spills_to_a_wrong_node_worker_on_liminal_path()
2740 -> Result<(), Box<dyn std::error::Error>> {
2741 let ns_store = pinned_store("t", &["n1"]).await?;
2742 // Only an n2 worker is live: no n1-labelled worker exists at all.
2743 let registry = ConnectedWorkerRegistry::default();
2744 let _n2 = register_node_worker(®istry, "t", "n2")?;
2745 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2746
2747 let row = unpinned_row("t");
2748 let selected = dispatch.select_liminal_worker(&row).await?.worker;
2749 assert!(
2750 selected.is_none(),
2751 "Pinned{{n1}} must NOT spill to the live n2 worker — it selects nothing \
2752 so the outbox retries/stalls until an n1 worker returns"
2753 );
2754 assert_eq!(row.node, None, "placement must never mutate the row's node");
2755 Ok(())
2756 }
2757
2758 /// #163 (authored pin wins): a row authored-pinned to `Some(n2)` STILL
2759 /// selects an n2 worker on the liminal path regardless of the namespace's
2760 /// `Prefer{n1}` — the per-activity pin is authoritative and placement never
2761 /// overrides it.
2762 #[tokio::test]
2763 async fn authored_node_pin_wins_over_namespace_prefer_on_liminal_path()
2764 -> Result<(), Box<dyn std::error::Error>> {
2765 let ns_store = prefer_store("t", &["n1"]).await?;
2766 let registry = ConnectedWorkerRegistry::default();
2767 let _n1 = register_node_worker(®istry, "t", "n1")?;
2768 let _n2 = register_node_worker(®istry, "t", "n2")?;
2769 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2770
2771 // Authored pin: node = Some("n2").
2772 let row = unpinned_row("t").with_node(Some(String::from("n2")));
2773 let selected = dispatch
2774 .select_liminal_worker(&row)
2775 .await?
2776 .worker
2777 .ok_or("the authored pin must select the n2 worker")?;
2778 assert_eq!(
2779 selected.node(),
2780 Some("n2"),
2781 "the authored Some(n2) pin is honoured regardless of the namespace Prefer{{n1}}"
2782 );
2783 // The authored node is preserved exactly (determinism gate).
2784 assert_eq!(row.node.as_deref(), Some("n2"));
2785 Ok(())
2786 }
2787
2788 /// #163 (byte-identical default): an `Unplaced` namespace selects any live
2789 /// worker on the liminal path exactly as the pre-Phase-2 single
2790 /// `select_worker` would — the ceiling/placement never engages.
2791 #[tokio::test]
2792 async fn unplaced_namespace_selects_any_worker_on_liminal_path()
2793 -> Result<(), Box<dyn std::error::Error>> {
2794 let ns_store: Arc<dyn NamespaceStore> = Arc::new(InMemoryStore::default());
2795 // Registered but left Unplaced (the default placement).
2796 ns_store
2797 .register_namespace("t", NamespaceOrigin::Explicit)
2798 .await?;
2799 let registry = ConnectedWorkerRegistry::default();
2800 let _n2 = register_node_worker(®istry, "t", "n2")?;
2801 let dispatch = liminal_dispatch(®istry, Arc::clone(&ns_store));
2802
2803 let selected = dispatch
2804 .select_liminal_worker(&unpinned_row("t"))
2805 .await?
2806 .worker
2807 .ok_or("an Unplaced namespace still selects a live worker")?;
2808 assert_eq!(
2809 selected.node(),
2810 Some("n2"),
2811 "an Unplaced namespace reaches any live worker, exactly as before"
2812 );
2813 Ok(())
2814 }
2815
2816 /// #163 (byte-identical, no cache): with NO placement cache attached, the
2817 /// liminal selection is the single `select_worker` off the row's own node —
2818 /// byte-identical to the pre-#163 construction. An unpinned row reaches any
2819 /// live worker; the namespace's `Prefer` is not even consulted.
2820 #[tokio::test]
2821 async fn no_cache_selection_is_byte_identical_to_pre_163()
2822 -> Result<(), Box<dyn std::error::Error>> {
2823 // The namespace prefers n1, but with no cache the preference is ignored.
2824 let _ns_store = prefer_store("t", &["n1"]).await?;
2825 let registry = ConnectedWorkerRegistry::default();
2826 let _n2 = register_node_worker(®istry, "t", "n2")?;
2827 // No `.with_placement_cache(...)`: the pre-#163 construction.
2828 let dispatch = RegistryLiminalDispatch::new(
2829 registry.clone(),
2830 Arc::new(NoopCallback),
2831 DeliveryGate::default(),
2832 );
2833
2834 let selected = dispatch
2835 .select_liminal_worker(&unpinned_row("t"))
2836 .await?
2837 .worker
2838 .ok_or("without a cache the unpinned row still selects any worker")?;
2839 assert_eq!(
2840 selected.node(),
2841 Some("n2"),
2842 "with no placement cache the selection is the unchanged any-worker path"
2843 );
2844 Ok(())
2845 }
2846 }
2847}