Skip to main content

aion_server/api/
worker_grpc.rs

1//! tonic `WorkerProtocol` service — bidirectional stream handler.
2
3use aion_proto::{
4    ProtoActivityDescriptor, ProtoActivityResult, ProtoRegisterWorker, ProtoWorkerInstanceIdentity,
5    generated::{
6        self,
7        worker_protocol_server::{WorkerProtocol, WorkerProtocolServer},
8    },
9};
10use tokio::sync::mpsc;
11use tokio_stream::wrappers::ReceiverStream;
12use tonic::{Request, Response, Status, Streaming};
13
14use crate::worker::PendingActivities;
15use crate::worker::dispatch::{ActivityCompletion, ActivityCompletionSink};
16use crate::worker::registry::{WorkerId, WorkerMessage};
17use crate::{CallerIdentity, ServerState};
18
19/// Cloneable tonic implementation for the worker bidirectional stream.
20#[derive(Clone)]
21pub struct WorkerGrpcService {
22    state: ServerState,
23}
24
25impl WorkerGrpcService {
26    /// Build a tonic worker service from shared server state.
27    #[must_use]
28    pub const fn new(state: ServerState) -> Self {
29        Self { state }
30    }
31}
32
33/// Construct the generated tonic server wrapper for the worker protocol.
34#[must_use]
35pub fn worker_service(state: ServerState) -> WorkerProtocolServer<WorkerGrpcService> {
36    WorkerProtocolServer::new(WorkerGrpcService::new(state))
37}
38
39#[tonic::async_trait]
40impl WorkerProtocol for WorkerGrpcService {
41    type StreamWorkerStream = ReceiverStream<Result<generated::ServerToWorker, Status>>;
42
43    async fn stream_worker(
44        &self,
45        request: Request<Streaming<generated::WorkerToServer>>,
46    ) -> Result<Response<Self::StreamWorkerStream>, Status> {
47        let metadata = request.metadata().clone();
48        let caller = worker_caller_from_metadata(&metadata, &self.state).await?;
49        let token_expires_at = token_expiration_from_metadata(&metadata, &self.state).await?;
50        let heartbeat_grace = self.state.runtime_config().worker.heartbeat_window;
51        let mut inbound = request.into_inner();
52
53        let first = inbound
54            .message()
55            .await?
56            .and_then(|msg| msg.message)
57            .ok_or_else(|| Status::invalid_argument("first message must be RegisterWorker"))?;
58
59        let register = match first {
60            generated::worker_to_server::Message::Register(r) => decode_register(r),
61            _ => {
62                return Err(Status::invalid_argument(
63                    "first message must be RegisterWorker",
64                ));
65            }
66        };
67        validate_worker_contracts(&self.state, &register)?;
68
69        let (task_tx, task_rx) = mpsc::channel::<Result<generated::ServerToWorker, Status>>(32);
70        let (worker_tx, worker_rx) = mpsc::channel(32);
71
72        let registration = self
73            .state
74            .worker_registry()
75            .accept_registration(self.state.namespace_guard(), &caller, &register, worker_tx)
76            .await
77            .map_err(|error| status_from_server_error(&error))?;
78
79        let pending = self.state.pending_activities().clone();
80        let heartbeat = self.state.heartbeat_tracker().clone();
81        let drain = self.state.drain_state().clone();
82        let registry = self.state.worker_registry().clone();
83        let liveness_waiters = self.state.grpc_liveness_waiters().clone();
84        let worker_id = registration
85            .worker_id()
86            .ok_or_else(|| Status::internal("worker registration missing id"))?;
87        heartbeat
88            .register_connection(worker_id, std::time::Instant::now())
89            .map_err(|error| status_from_server_error(&error))?;
90        // A worker serves a SET of namespaces; the ack echoes them joined in
91        // stable order purely for the worker's logs (the RegisterAck namespace
92        // field is informational, not a routing input).
93        let authorized_namespace = registration
94            .namespaces()
95            .filter(|namespaces| !namespaces.is_empty())
96            .ok_or_else(|| Status::internal("worker registration missing namespace"))?
97            .iter()
98            .cloned()
99            .collect::<Vec<_>>()
100            .join(",");
101
102        // RegisterAck ordering guarantee: the ack is enqueued on `task_tx`
103        // BEFORE the write forwarder that copies dispatched tasks onto the
104        // same channel is spawned, so no task frame can precede it on the
105        // wire. This is a structural ordering proof, not a timing hope.
106        task_tx
107            .try_send(Ok(register_ack_frame(
108                worker_id,
109                &authorized_namespace,
110                heartbeat_grace,
111            )))
112            .map_err(|_| Status::internal("worker response channel closed before RegisterAck"))?;
113
114        tokio::spawn(async move {
115            let write_handle = spawn_write_forwarder(worker_rx, task_tx.clone());
116
117            // Armed BEFORE the inbound loop runs: the sweep in its `Drop`
118            // fires on every exit from this task — clean stream end, stream
119            // error, token expiry, even a panic unwinding `process_inbound`.
120            // The unbounded dispatch wait depends on it.
121            let teardown = StreamTeardown {
122                worker_id,
123                heartbeat: &heartbeat,
124                registry: &registry,
125                pending: &pending,
126                drain: &drain,
127                liveness_waiters: &liveness_waiters,
128            };
129            let session = WorkerSession {
130                worker_id,
131                pending: &pending,
132                heartbeat: &heartbeat,
133                drain: &drain,
134                token_expires_at,
135                heartbeat_grace,
136                task_tx: task_tx.clone(),
137                liveness_waiters: liveness_waiters.clone(),
138            };
139            if let Err(status) = process_inbound(inbound, session).await {
140                tracing::info!(
141                    worker_id = ?worker_id,
142                    %status,
143                    "worker stream closed with status"
144                );
145            }
146
147            write_handle.abort();
148            drop(task_tx);
149            drop(teardown);
150            // The teardown sweep already deregistered the stream; consuming
151            // the registration here is an idempotent no-op that still
152            // surfaces a poisoned-lock error loudly.
153            if let Err(error) = registration.deregister() {
154                tracing::error!(
155                    worker_id = ?worker_id,
156                    %error,
157                    "worker deregistration failed during stream teardown"
158                );
159            }
160        });
161
162        Ok(Response::new(ReceiverStream::new(task_rx)))
163    }
164}
165
166/// Spawn the write forwarder: it copies registry-delivered [`WorkerMessage`]s
167/// onto the worker's response stream, and — the #176 zombie fix — TERMINATES
168/// the RPC when the registry drops this worker's delivery sender.
169///
170/// `recv` returning `None` means every delivery sender is gone: the worker
171/// was DEREGISTERED while its stream stayed open (the heartbeat expiry sweep,
172/// or any future administrative removal). Silently deregistering would leave
173/// the worker a zombie — connected but unroutable, believing it is
174/// registered, its heartbeats rejected as "not in flight", never
175/// re-registering until its own stream happens to end. Ending the RPC with a
176/// retryable `Unavailable` status makes the worker OBSERVE the
177/// deregistration and re-register through its reconnect machinery. (On the
178/// normal teardown path this task is aborted before the registration is
179/// consumed, so the status is never sent to a worker that hung up; a send
180/// failure means the response stream's consumer is already gone, so there is
181/// no one left to signal.)
182fn spawn_write_forwarder(
183    mut worker_rx: mpsc::Receiver<WorkerMessage>,
184    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
185) -> tokio::task::JoinHandle<()> {
186    tokio::spawn(async move {
187        while let Some(message) = worker_rx.recv().await {
188            let msg = encode_server_to_worker(message);
189            if task_tx.send(Ok(msg)).await.is_err() {
190                return;
191            }
192        }
193        let _ = task_tx
194            .send(Err(Status::unavailable(
195                "worker was deregistered by the server (heartbeat window expired); \
196                 reconnect and re-register",
197            )))
198            .await;
199    })
200}
201
202/// Drop guard that sweeps a torn-down worker stream's in-flight activities:
203/// failed back to the engine mid-run, or parked for restart recovery under a
204/// graceful drain (#207) — see [`teardown_worker_stream`].
205///
206/// A guard rather than a call site so the sweep cannot be skipped by any
207/// exit from the stream task — including a panic unwinding the inbound
208/// loop, which would otherwise leave every dispatch blocked on that worker
209/// waiting forever.
210struct StreamTeardown<'a> {
211    worker_id: WorkerId,
212    heartbeat: &'a crate::worker::HeartbeatTracker,
213    registry: &'a crate::worker::ConnectedWorkerRegistry,
214    pending: &'a PendingActivities,
215    drain: &'a crate::shutdown::DrainState,
216    /// The probe's answer-correlation registry (#197), released here for the
217    /// same reason the sweep runs here: a guard fires on EVERY exit from the
218    /// stream task, a plain call after the loop does not.
219    liveness_waiters: &'a crate::worker::GrpcLivenessWaiters,
220}
221
222impl Drop for StreamTeardown<'_> {
223    fn drop(&mut self) {
224        teardown_worker_stream(
225            self.worker_id,
226            self.heartbeat,
227            self.registry,
228            self.pending,
229            self.drain,
230        );
231        // #197: this worker can no longer answer anything. Inside the guard
232        // rather than after the inbound loop, because a panic unwinding
233        // `process_inbound` would skip a plain call and leak the armed waiter
234        // for the life of the process — one entry per panicking stream on a
235        // server built never to die. It changes no verdict: the probe's own
236        // cadence judges an unanswered ping either way. It only stops the leak,
237        // and stops a re-registered worker id inheriting a sequence from a
238        // stream that is gone.
239        if let Err(error) = self.liveness_waiters.disarm(self.worker_id) {
240            tracing::error!(
241                worker_id = ?self.worker_id,
242                %error,
243                "gRPC liveness waiter map is poisoned; armed pings for departed workers can no \
244                 longer be released"
245            );
246        }
247    }
248}
249
250/// Sweep a torn-down worker stream's in-flight activities.
251///
252/// The stream is the worker's liveness. When it ends mid-run — process death,
253/// network disconnect, expired token — every activity still assigned to this
254/// worker must be failed back through the completion sink as a retryable
255/// lost-worker error. The activity dispatch wait is unbounded by design (the
256/// engine imposes no activity timeout), so this sweep is what unblocks
257/// dispatches whose worker died mid-activity; the engine's retry policy then
258/// decides re-dispatch.
259///
260/// Under a graceful drain (#207) the stream ending is the EXPECTED worker
261/// response to the drain request, not a death: the worker's in-flight tasks
262/// are PARKED for restart recovery instead — nothing is recorded, nothing is
263/// delivered, the durable log keeps its dangling scheduled/started trail
264/// exactly as a kill -9 would, and post-restart replay re-dispatches it. The
265/// park-vs-fail branch keys on
266/// [`DrainState::is_draining`](crate::shutdown::DrainState::is_draining): a
267/// worker lost while the server is NOT draining still fails-and-retries
268/// byte-identically to before.
269fn teardown_worker_stream(
270    worker_id: WorkerId,
271    heartbeat: &crate::worker::HeartbeatTracker,
272    registry: &crate::worker::ConnectedWorkerRegistry,
273    pending: &PendingActivities,
274    drain: &crate::shutdown::DrainState,
275) {
276    if drain.is_draining() {
277        match heartbeat.park_disconnected_worker(worker_id, registry, pending) {
278            Ok(report) if report.tasks.is_empty() => {}
279            Ok(report) => {
280                tracing::info!(
281                    worker_id = ?worker_id,
282                    parked_tasks = report.tasks.len(),
283                    "worker stream ended during drain; in-flight activities \
284                     parked for restart recovery"
285                );
286            }
287            Err(error) => {
288                tracing::error!(
289                    worker_id = ?worker_id,
290                    %error,
291                    "failed to park draining worker's in-flight activities"
292                );
293            }
294        }
295    } else {
296        match heartbeat.fail_disconnected_worker(worker_id, registry, pending) {
297            Ok(report) if report.tasks.is_empty() => {}
298            Ok(report) => {
299                tracing::warn!(
300                    worker_id = ?worker_id,
301                    failed_tasks = report.tasks.len(),
302                    "worker disconnected with in-flight activities; \
303                     surfaced as transport losses, to be re-dispatched \
304                     attempt-neutrally"
305                );
306            }
307            Err(error) => {
308                tracing::error!(
309                    worker_id = ?worker_id,
310                    %error,
311                    "failed to sweep disconnected worker's in-flight activities"
312                );
313            }
314        }
315    }
316    // In-flight accounting may have just reached zero; wake any drain
317    // waiter so shutdown does not sit out its full timeout.
318    drain.notify_activity_drained();
319}
320
321struct WorkerSession<'a> {
322    worker_id: WorkerId,
323    pending: &'a PendingActivities,
324    heartbeat: &'a crate::worker::HeartbeatTracker,
325    drain: &'a crate::shutdown::DrainState,
326    token_expires_at: Option<u64>,
327    heartbeat_grace: std::time::Duration,
328    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
329    /// The liveness probe's answer-correlation registry (#197). The probe
330    /// pushes pings from its own timer loop and cannot see this stream; this
331    /// handle is the only thing the two halves share.
332    liveness_waiters: crate::worker::GrpcLivenessWaiters,
333}
334
335async fn process_inbound(
336    mut inbound: Streaming<generated::WorkerToServer>,
337    session: WorkerSession<'_>,
338) -> Result<(), Status> {
339    let mut expired_since: Option<std::time::Instant> = None;
340    while let Some(msg) = inbound.message().await? {
341        refresh_connection_lease(&session)?;
342        let Some(inner) = msg.message else {
343            continue;
344        };
345        match inner {
346            generated::worker_to_server::Message::Result(result) => {
347                let proto_result = decode_activity_result(result);
348                match ActivityCompletion::try_from(proto_result) {
349                    Ok(completion) => {
350                        let workflow_id = completion.workflow_id.clone();
351                        let activity_id = completion.activity_id.clone();
352                        match session.pending.complete_activity(completion) {
353                            Ok(()) => {
354                                if let Err(error) = session.heartbeat.complete_task(
355                                    session.worker_id,
356                                    &workflow_id,
357                                    &activity_id,
358                                ) {
359                                    // A poisoned liveness tracker would also break
360                                    // the lost-worker sweep the unbounded dispatch
361                                    // wait relies on — never swallow it.
362                                    tracing::error!(
363                                        worker_id = ?session.worker_id,
364                                        workflow_id = %workflow_id,
365                                        activity_id = %activity_id,
366                                        %error,
367                                        "failed to clear in-flight tracking for completed activity"
368                                    );
369                                }
370                                session.drain.notify_activity_drained();
371                            }
372                            Err(error) => {
373                                // Leave the current worker's liveness entry intact
374                                // when the generation proof is wrong. Its stopped
375                                // heartbeat pump will drive the normal loss/retry path.
376                                tracing::error!(
377                                    worker_id = ?session.worker_id,
378                                    workflow_id = %workflow_id,
379                                    activity_id = %activity_id,
380                                    %error,
381                                    "activity completion handoff failed"
382                                );
383                            }
384                        }
385                        // Ack every well-formed result frame — including
386                        // duplicates with no pending waiter; their re-report
387                        // obligation is equally discharged. `try_send`: a
388                        // worker that stopped draining its receive side must
389                        // not wedge the inbound loop; a dropped ack is
390                        // recovered by the next-session re-report.
391                        let ack = result_ack_frame(&workflow_id, &activity_id);
392                        if let Err(error) = session.task_tx.try_send(Ok(ack)) {
393                            tracing::warn!(
394                                worker_id = ?session.worker_id,
395                                workflow_id = %workflow_id,
396                                activity_id = %activity_id,
397                                %error,
398                                "result ack dropped: worker stream channel unavailable"
399                            );
400                        }
401                    }
402                    Err(error) => {
403                        // Malformed result: no ids to ack with. Loud, never
404                        // silent — the worker's entry will re-report and
405                        // re-fail visibly each session.
406                        tracing::error!(
407                            worker_id = ?session.worker_id,
408                            %error,
409                            "malformed activity result frame; no ack sent"
410                        );
411                    }
412                }
413            }
414            generated::worker_to_server::Message::Register(_) => {
415                warn_duplicate_registration(session.worker_id);
416            }
417            // #197: the answer to a transport liveness ping. It proves the one
418            // fact dispatch eligibility is gated on — that the server reached
419            // this worker's dispatch path — but it proves it only through the
420            // probe, which is the party that knows which sequence it asked.
421            // Nothing is recorded here beyond handing the echo across.
422            generated::worker_to_server::Message::LivenessAnswer(answer) => {
423                deliver_liveness_answer(&session, answer.liveness_ping);
424            }
425            generated::worker_to_server::Message::Heartbeat(heartbeat_msg) => {
426                // Empty task ids are the connection-level lease beat. The frame
427                // already advanced the lease above; it intentionally has no
428                // per-task liveness entry to update.
429                if heartbeat_msg.workflow_id.is_none() && heartbeat_msg.activity_id.is_none() {
430                    continue;
431                }
432                if let Err(error) = session.heartbeat.record_heartbeat(
433                    session.worker_id,
434                    decode_heartbeat(heartbeat_msg),
435                    std::time::Instant::now(),
436                ) {
437                    // Malformed frames and heartbeats for untracked tasks
438                    // are worker-side defects worth surfacing; a poisoned
439                    // tracker lock is a server-side corruption signal that
440                    // must never vanish silently.
441                    if matches!(error, crate::ServerError::LockPoisoned { .. }) {
442                        tracing::error!(
443                            worker_id = ?session.worker_id,
444                            %error,
445                            "heartbeat tracker lock poisoned; liveness state untrustworthy"
446                        );
447                    } else {
448                        tracing::warn!(
449                            worker_id = ?session.worker_id,
450                            %error,
451                            "worker heartbeat rejected"
452                        );
453                    }
454                }
455                enforce_token_expiration(&session, &mut expired_since).await?;
456            }
457        }
458    }
459    Ok(())
460}
461
462/// Hand one `LivenessAnswer`'s echoed sequence to the probe's correlation
463/// registry (#197).
464///
465/// Never silent. An unmatched answer is a real fact about this link — the
466/// worker replied after the probe's cadence had already expired, or echoed a
467/// sequence that was not asked — and it is exactly the evidence an operator
468/// needs when a worker keeps failing to clear its probation while looking
469/// healthy from the outside. A poisoned waiter map is louder still: it means
470/// no gRPC worker on this server can clear probation at all.
471fn deliver_liveness_answer(session: &WorkerSession<'_>, sequence: u64) {
472    match session.liveness_waiters.answer(session.worker_id, sequence) {
473        Ok(true) => {}
474        Ok(false) => tracing::warn!(
475            worker_id = ?session.worker_id,
476            liveness_ping = sequence,
477            "worker answered a liveness ping the server was no longer waiting for; the answer \
478             arrived after its probe cadence expired, or echoed a sequence that was never asked. \
479             It banks NOTHING toward the dispatch probation"
480        ),
481        Err(error) => tracing::error!(
482            worker_id = ?session.worker_id,
483            liveness_ping = sequence,
484            %error,
485            "gRPC liveness waiter map is poisoned; no gRPC worker on this server can clear its \
486             dispatch probation until the process is restarted"
487        ),
488    }
489}
490
491fn refresh_connection_lease(session: &WorkerSession<'_>) -> Result<(), Status> {
492    session
493        .heartbeat
494        .record_connection_activity(session.worker_id, std::time::Instant::now())
495        .map(|_| ())
496        .map_err(|error| {
497            tracing::error!(
498                worker_id = ?session.worker_id,
499                %error,
500                "failed to advance worker connection lease"
501            );
502            status_from_server_error(&error)
503        })
504}
505
506fn warn_duplicate_registration(worker_id: WorkerId) {
507    tracing::warn!(
508        worker_id = ?worker_id,
509        "ignoring subsequent RegisterWorker message; \
510         only the first registration is accepted per stream"
511    );
512}
513
514async fn enforce_token_expiration(
515    session: &WorkerSession<'_>,
516    expired_since: &mut Option<std::time::Instant>,
517) -> Result<(), Status> {
518    if !token_expired(session.token_expires_at) {
519        return Ok(());
520    }
521    let first_expired = *expired_since.get_or_insert_with(std::time::Instant::now);
522    let _ = session
523        .task_tx
524        .send(Err(Status::unauthenticated(
525            "worker token expired; re-authentication required",
526        )))
527        .await;
528    if first_expired.elapsed() >= session.heartbeat_grace {
529        return Err(Status::unauthenticated("worker token expired"));
530    }
531    Ok(())
532}
533
534async fn worker_caller_from_metadata(
535    metadata: &tonic::metadata::MetadataMap,
536    state: &ServerState,
537) -> Result<CallerIdentity, Status> {
538    crate::api::grpc::caller_from_metadata(metadata, state).await
539}
540
541async fn token_expiration_from_metadata(
542    metadata: &tonic::metadata::MetadataMap,
543    state: &ServerState,
544) -> Result<Option<u64>, Status> {
545    if !state.runtime_config().auth.enabled {
546        return Ok(None);
547    }
548    #[cfg(feature = "auth")]
549    {
550        let bearer = metadata
551            .get("authorization")
552            .and_then(|value| value.to_str().ok())
553            .and_then(parse_bearer)
554            .ok_or_else(|| Status::unauthenticated("missing bearer token"))?;
555        let Some(cache) = state.jwks_cache() else {
556            return Err(Status::unauthenticated("invalid bearer token"));
557        };
558        return cache
559            .validate(&bearer)
560            .await
561            .map(|claims| Some(claims.expires_at()))
562            .map_err(|_error| Status::unauthenticated("invalid bearer token"));
563    }
564    #[cfg(not(feature = "auth"))]
565    {
566        let _ = metadata;
567        // Yield to preserve the async signature required by the auth-feature branch.
568        tokio::task::yield_now().await;
569        Ok(None)
570    }
571}
572
573#[cfg(feature = "auth")]
574fn parse_bearer(value: &str) -> Option<String> {
575    let token = value.strip_prefix("Bearer ")?.trim();
576    if token.is_empty() {
577        return None;
578    }
579    Some(token.to_owned())
580}
581
582fn token_expired(expires_at: Option<u64>) -> bool {
583    expires_at.is_some_and(|expires_at| {
584        #[cfg(feature = "auth")]
585        {
586            crate::auth::jwks::is_expired(expires_at)
587        }
588        #[cfg(not(feature = "auth"))]
589        {
590            let _ = expires_at;
591            false
592        }
593    })
594}
595
596fn status_from_server_error(error: &crate::ServerError) -> Status {
597    let wire = error.to_wire_error();
598    if wire.code == aion_proto::WireErrorCode::NamespaceDenied {
599        Status::permission_denied(wire.message)
600    } else {
601        Status::internal(wire.message)
602    }
603}
604
605/// Build the positive registration acknowledgement frame — the guaranteed
606/// first frame on every successful worker response stream.
607fn register_ack_frame(
608    worker_id: WorkerId,
609    namespace: &str,
610    heartbeat_window: std::time::Duration,
611) -> generated::ServerToWorker {
612    generated::ServerToWorker {
613        message: Some(generated::server_to_worker::Message::RegisterAck(
614            generated::RegisterAck {
615                worker_id: worker_id.value(),
616                namespace: namespace.to_owned(),
617                heartbeat_window_ms: u64::try_from(heartbeat_window.as_millis())
618                    .unwrap_or(u64::MAX),
619            },
620        )),
621    }
622}
623
624/// Build the per-result acknowledgement frame for a consumed `ActivityResult`.
625fn result_ack_frame(
626    workflow_id: &aion_core::WorkflowId,
627    activity_id: &aion_core::ActivityId,
628) -> generated::ServerToWorker {
629    generated::ServerToWorker {
630        message: Some(generated::server_to_worker::Message::ResultAck(
631            generated::ResultAck {
632                workflow_id: Some(generated::WorkflowId {
633                    uuid: workflow_id.to_string(),
634                }),
635                activity_id: Some(generated::ActivityId {
636                    sequence_position: activity_id.sequence_position(),
637                }),
638            },
639        )),
640    }
641}
642
643fn decode_register(r: generated::RegisterWorker) -> ProtoRegisterWorker {
644    ProtoRegisterWorker {
645        namespaces: r.namespaces,
646        activity_types: r.activity_types,
647        task_queue: r.task_queue,
648        node: r.node,
649        activities: r
650            .activities
651            .into_iter()
652            .map(|activity| ProtoActivityDescriptor {
653                name: activity.name,
654                input_schema_json: activity.input_schema_json,
655                output_schema_json: activity.output_schema_json,
656            })
657            .collect(),
658        identity: r.identity,
659        instance: r.instance.map(|instance| ProtoWorkerInstanceIdentity {
660            deployment: instance.deployment,
661            instance_id: instance.instance_id,
662        }),
663    }
664}
665
666fn validate_worker_contracts(
667    state: &ServerState,
668    register: &ProtoRegisterWorker,
669) -> Result<(), Status> {
670    let advertised = register
671        .activities
672        .iter()
673        .map(|activity| {
674            let input_schema =
675                serde_json::from_str(&activity.input_schema_json).map_err(|error| {
676                    Status::invalid_argument(format!(
677                        "worker activity `{}` input_schema_json is invalid: {error}",
678                        activity.name
679                    ))
680                })?;
681            let output_schema =
682                serde_json::from_str(&activity.output_schema_json).map_err(|error| {
683                    Status::invalid_argument(format!(
684                        "worker activity `{}` output_schema_json is invalid: {error}",
685                        activity.name
686                    ))
687                })?;
688            Ok(aion_package::ActivityDescriptor {
689                name: activity.name.clone(),
690                input_schema,
691                output_schema,
692            })
693        })
694        .collect::<Result<Vec<_>, Status>>()?;
695    // Mirrors the liminal transport's no-catalog admission: a state built
696    // from parts without an engine handle has no durable catalog, so no
697    // deployed `.v4` contract can exist to contradict this worker. Refusing
698    // here would turn every registration away forever on such a server; the
699    // strict field-level check applies on every engine-backed state.
700    let Ok(engine) = state.engine() else {
701        tracing::warn!(
702            task_queue = %register.task_queue,
703            identity = %register.identity,
704            "worker contract check skipped: server state has no engine handle, \
705             so no deployed contracts exist to check against"
706        );
707        return Ok(());
708    };
709    // Both advertised forms travel into the gate together — see
710    // [`crate::worker::contracts::WorkerAdvertisement`] for why carrying only
711    // one of them makes a refusal contradict itself.
712    let activity_types = register
713        .activity_types
714        .iter()
715        .cloned()
716        .collect::<std::collections::BTreeSet<_>>();
717    crate::worker::contracts::validate_worker_contracts(
718        &engine,
719        state.worker_registry().admission_audit(),
720        &register.task_queue,
721        crate::worker::registry::optional_node(&register.node).as_deref(),
722        &register.identity,
723        crate::worker::contracts::WorkerAdvertisement {
724            activity_types: &activity_types,
725            contracts: &advertised,
726        },
727    )
728    .map_err(|error| match error {
729        crate::worker::contracts::ContractAdmissionError::Mismatch { .. } => {
730            Status::failed_precondition(error.to_string())
731        }
732        crate::worker::contracts::ContractAdmissionError::Catalog { .. } => {
733            Status::internal(error.to_string())
734        }
735    })
736}
737
738fn encode_server_to_worker(message: WorkerMessage) -> generated::ServerToWorker {
739    let message = match message {
740        WorkerMessage::ActivityTask(task) => {
741            generated::server_to_worker::Message::Task(encode_task(*task))
742        }
743        WorkerMessage::DrainRequest => {
744            generated::server_to_worker::Message::Drain(generated::DrainRequest {})
745        }
746        // #197: the liveness ping rides the SAME forwarder a dispatch rides,
747        // deliberately — that is the whole point of it. Encoding it anywhere
748        // else would measure a channel no dispatch travels.
749        WorkerMessage::LivenessPing(ping) => {
750            generated::server_to_worker::Message::LivenessPing(generated::LivenessPing {
751                liveness_ping: ping.liveness_ping,
752                silence_window_ms: ping.silence_window_ms,
753            })
754        }
755        // #233: the cancel rides the SAME forwarder the dispatch rode, so it
756        // cannot overtake the task it interrupts and a worker whose dispatch
757        // path is dead cannot appear to have been told.
758        WorkerMessage::CancelActivity(cancel) => {
759            generated::server_to_worker::Message::CancelActivity(generated::CancelActivity {
760                workflow_id: cancel
761                    .workflow_id
762                    .map(|id| generated::WorkflowId { uuid: id.uuid }),
763                activity_id: cancel.activity_id.map(|id| generated::ActivityId {
764                    sequence_position: id.sequence_position,
765                }),
766            })
767        }
768    };
769    generated::ServerToWorker {
770        message: Some(message),
771    }
772}
773
774fn encode_task(task: aion_proto::ProtoActivityTask) -> generated::ActivityTask {
775    generated::ActivityTask {
776        workflow_id: task
777            .workflow_id
778            .map(|id| generated::WorkflowId { uuid: id.uuid }),
779        activity_id: task.activity_id.map(|id| generated::ActivityId {
780            sequence_position: id.sequence_position,
781        }),
782        activity_type: task.activity_type,
783        input: task.input.map(|p| generated::Payload {
784            content_type: p.content_type,
785            bytes: p.bytes,
786        }),
787        attempt: task.attempt,
788        labels: task.labels,
789        run_id: task.run_id.map(|id| generated::RunId { uuid: id.uuid }),
790        completion_token: task.completion_token,
791        idempotency_key: task.idempotency_key,
792    }
793}
794
795fn decode_activity_result(r: generated::ActivityResult) -> ProtoActivityResult {
796    ProtoActivityResult {
797        workflow_id: r
798            .workflow_id
799            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
800        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
801            sequence_position: id.sequence_position,
802        }),
803        outcome: r.outcome.map(decode_outcome),
804        run_id: r.run_id.map(|id| aion_proto::ProtoRunId { uuid: id.uuid }),
805        completion_token: r.completion_token,
806    }
807}
808
809fn decode_heartbeat(r: generated::Heartbeat) -> aion_proto::ProtoHeartbeat {
810    aion_proto::ProtoHeartbeat {
811        workflow_id: r
812            .workflow_id
813            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
814        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
815            sequence_position: id.sequence_position,
816        }),
817        progress: r.progress.map(|p| aion_proto::ProtoPayload {
818            content_type: p.content_type,
819            bytes: p.bytes,
820        }),
821    }
822}
823
824fn decode_outcome(
825    outcome: generated::activity_result::Outcome,
826) -> aion_proto::proto_activity_result::Outcome {
827    match outcome {
828        generated::activity_result::Outcome::Result(p) => {
829            aion_proto::proto_activity_result::Outcome::Result(aion_proto::ProtoPayload {
830                content_type: p.content_type,
831                bytes: p.bytes,
832            })
833        }
834        generated::activity_result::Outcome::Error(e) => {
835            aion_proto::proto_activity_result::Outcome::Error(aion_proto::ProtoActivityError {
836                kind: e.kind,
837                message: e.message,
838                details: e.details.map(|p| aion_proto::ProtoPayload {
839                    content_type: p.content_type,
840                    bytes: p.bytes,
841                }),
842            })
843        }
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use std::time::{Duration, Instant};
850
851    use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
852
853    use crate::shutdown::DrainState;
854    use crate::worker::dispatch::{
855        ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
856    };
857    use crate::worker::heartbeat::InFlightActivity;
858    use crate::worker::registry::ConnectedWorkerRegistry;
859    use crate::worker::{HeartbeatTracker, PendingActivities};
860
861    use super::{decode_register, teardown_worker_stream};
862
863    type TestError = Box<dyn std::error::Error>;
864
865    #[test]
866    fn decode_register_maps_tag_seven_instance_without_changing_absent_registration() {
867        let generated = super::generated::RegisterWorker {
868            namespaces: vec!["orders".to_owned()],
869            activity_types: vec!["shell".to_owned()],
870            task_queue: "shell".to_owned(),
871            node: "node-a".to_owned(),
872            activities: Vec::new(),
873            identity: "build-a".to_owned(),
874            instance: Some(super::generated::WorkerInstanceIdentity {
875                deployment: "shells".to_owned(),
876                instance_id: "instance-1".to_owned(),
877            }),
878        };
879        let mapped = decode_register(generated.clone());
880        let instance = mapped.instance.as_ref();
881        assert_eq!(
882            instance.map(|value| value.deployment.as_str()),
883            Some("shells")
884        );
885        assert_eq!(
886            instance.map(|value| value.instance_id.as_str()),
887            Some("instance-1")
888        );
889
890        let mut absent = generated;
891        absent.instance = None;
892        let mapped_absent = decode_register(absent);
893        assert!(mapped_absent.instance.is_none());
894        assert_eq!(mapped_absent.identity, "build-a");
895    }
896
897    /// One tracked in-flight dispatch with a live pending waiter, ready for a
898    /// stream teardown: the registered worker, the shared tracker/pending/drain
899    /// state, and the waiter's receiver.
900    struct TeardownFixture {
901        registry: ConnectedWorkerRegistry,
902        tracker: HeartbeatTracker,
903        pending: PendingActivities,
904        drain: DrainState,
905        worker_id: crate::worker::registry::WorkerId,
906        workflow_id: WorkflowId,
907        activity_id: ActivityId,
908        completion_token: crate::worker::CompletionToken,
909        rx: std::sync::mpsc::Receiver<Result<String, String>>,
910        /// Held so the registered worker stays routable until the teardown
911        /// under test deregisters it (dropping the guard would race that).
912        _registration: crate::worker::registry::WorkerRegistration,
913    }
914
915    fn fixture() -> Result<TeardownFixture, TestError> {
916        let registry = ConnectedWorkerRegistry::default();
917        let (tx, _rx) = tokio::sync::mpsc::channel(1);
918        let activity_types = [String::from("greet")];
919        let registration = registry.register("default", activity_types.iter(), tx)?;
920        let worker_id = registration
921            .worker_id()
922            .ok_or("test worker registration missing id")?;
923        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
924        let pending = PendingActivities::default();
925        let workflow_id = WorkflowId::new_v4();
926        let activity_id = ActivityId::from_sequence_position(0);
927        let (completion_token, rx) =
928            pending.insert_for_test(workflow_id.clone(), activity_id.clone())?;
929        tracker.track_task(
930            worker_id,
931            InFlightActivity {
932                workflow_id: workflow_id.clone(),
933                activity_id: activity_id.clone(),
934                attempt: 1,
935                completion_token: completion_token.clone(),
936            },
937            Instant::now(),
938        )?;
939        Ok(TeardownFixture {
940            registry,
941            tracker,
942            pending,
943            drain: DrainState::default(),
944            worker_id,
945            workflow_id,
946            activity_id,
947            completion_token,
948            rx,
949            _registration: registration,
950        })
951    }
952
953    /// #207: with drain begun, a stream teardown PARKS the in-flight dispatch —
954    /// the waiter resolves with the ephemeral parked sentinel, no lost-worker
955    /// failure is synthesized, and the tracker empties for drain accounting.
956    #[test]
957    fn teardown_under_drain_parks_instead_of_failing() -> Result<(), TestError> {
958        let fixture = fixture()?;
959        assert!(fixture.drain.begin());
960
961        teardown_worker_stream(
962            fixture.worker_id,
963            &fixture.tracker,
964            &fixture.registry,
965            &fixture.pending,
966            &fixture.drain,
967        );
968
969        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
970        assert_eq!(
971            resolved,
972            Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
973            "a drain teardown must resolve the waiter with the parked sentinel"
974        );
975        assert_eq!(fixture.tracker.in_flight_count()?, 0);
976        assert!(
977            !fixture.tracker.is_tracked(
978                fixture.worker_id,
979                &fixture.workflow_id,
980                &fixture.activity_id
981            )?,
982            "parking must retire the tracked entry"
983        );
984        Ok(())
985    }
986
987    /// Regression pin: WITHOUT drain, the teardown path still resolves the
988    /// waiter with a lost-worker failure — but in the TRANSPORT domain, not the
989    /// action's.
990    ///
991    /// The old contract here was `retryable:`, and that framing was the Hit D
992    /// defect: an authored retry policy governs how often the ACTION may fail,
993    /// and an activity with no policy (the SDK default) turned a worker death
994    /// into a TERMINAL failure. Worker loss now carries the `lost:` class, which
995    /// the engine re-dispatches attempt-neutrally.
996    #[test]
997    fn teardown_without_drain_fails_with_the_transport_domain_lost_worker_class()
998    -> Result<(), TestError> {
999        let fixture = fixture()?;
1000
1001        teardown_worker_stream(
1002            fixture.worker_id,
1003            &fixture.tracker,
1004            &fixture.registry,
1005            &fixture.pending,
1006            &fixture.drain,
1007        );
1008
1009        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1010        let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1011        assert!(
1012            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1013            "a mid-run teardown must surface the TRANSPORT-domain loss class, never the \
1014             action's retry vocabulary: {reason}"
1015        );
1016        assert!(
1017            reason.contains("lost before reporting activity result"),
1018            "the failure must name worker loss: {reason}"
1019        );
1020        assert_eq!(fixture.tracker.in_flight_count()?, 0);
1021        Ok(())
1022    }
1023
1024    /// R2 fencing red: after heartbeat loss resolves worker A's attempt and a
1025    /// retry installs worker B's waiter for the same activity, A's late result
1026    /// must not resolve B's generation.
1027    #[test]
1028    fn stale_worker_completion_after_heartbeat_loss_does_not_resolve_retry() -> Result<(), TestError>
1029    {
1030        let fixture = fixture()?;
1031        teardown_worker_stream(
1032            fixture.worker_id,
1033            &fixture.tracker,
1034            &fixture.registry,
1035            &fixture.pending,
1036            &fixture.drain,
1037        );
1038        let first = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1039        assert!(
1040            first
1041                .err()
1042                .is_some_and(|reason| reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX)),
1043            "worker A loss must release attempt 1 in the transport-loss class"
1044        );
1045
1046        let (retry_token, retry_rx) = fixture
1047            .pending
1048            .insert_for_test(fixture.workflow_id.clone(), fixture.activity_id.clone())?;
1049        let rejected = fixture.pending.complete_activity(ActivityCompletion {
1050            workflow_id: fixture.workflow_id,
1051            activity_id: fixture.activity_id,
1052            run_id: None,
1053            completion_token: fixture.completion_token,
1054            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1055                ContentType::Json,
1056                br#"{"worker":"A","stale":true}"#.to_vec(),
1057            )),
1058        });
1059
1060        assert!(matches!(
1061            rejected,
1062            Err(crate::ServerError::ActivityCompletionRejected { .. })
1063        ));
1064        drop(retry_token);
1065        assert!(
1066            retry_rx.recv_timeout(Duration::from_millis(50)).is_err(),
1067            "worker A's late completion must be rejected instead of resolving worker B's retry"
1068        );
1069        Ok(())
1070    }
1071}