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;
16use crate::worker::registry::{WorkerId, WorkerMessage};
17use crate::{CallerIdentity, ServerState};
18
19mod refusal_arm;
20mod register_ack;
21
22use refusal_arm::handle_task_refusal;
23use register_ack::{recorded_capacity, register_ack_frame};
24
25/// Cloneable tonic implementation for the worker bidirectional stream.
26#[derive(Clone)]
27pub struct WorkerGrpcService {
28    state: ServerState,
29}
30
31impl WorkerGrpcService {
32    /// Build a tonic worker service from shared server state.
33    #[must_use]
34    pub const fn new(state: ServerState) -> Self {
35        Self { state }
36    }
37}
38
39/// Construct the generated tonic server wrapper for the worker protocol.
40#[must_use]
41pub fn worker_service(state: ServerState) -> WorkerProtocolServer<WorkerGrpcService> {
42    WorkerProtocolServer::new(WorkerGrpcService::new(state))
43}
44
45#[tonic::async_trait]
46impl WorkerProtocol for WorkerGrpcService {
47    type StreamWorkerStream = ReceiverStream<Result<generated::ServerToWorker, Status>>;
48
49    async fn stream_worker(
50        &self,
51        request: Request<Streaming<generated::WorkerToServer>>,
52    ) -> Result<Response<Self::StreamWorkerStream>, Status> {
53        let metadata = request.metadata().clone();
54        let caller = worker_caller_from_metadata(&metadata, &self.state).await?;
55        let token_expires_at = token_expiration_from_metadata(&metadata, &self.state).await?;
56        let heartbeat_grace = self.state.runtime_config().worker.heartbeat_window;
57        let mut inbound = request.into_inner();
58
59        let first = inbound
60            .message()
61            .await?
62            .and_then(|msg| msg.message)
63            .ok_or_else(|| Status::invalid_argument("first message must be RegisterWorker"))?;
64
65        let register = match first {
66            generated::worker_to_server::Message::Register(r) => decode_register(r),
67            _ => {
68                return Err(Status::invalid_argument(
69                    "first message must be RegisterWorker",
70                ));
71            }
72        };
73        validate_worker_contracts(&self.state, &register)?;
74
75        let (task_tx, task_rx) = mpsc::channel::<Result<generated::ServerToWorker, Status>>(32);
76        let (worker_tx, worker_rx) = mpsc::channel(32);
77
78        let registration = self
79            .state
80            .worker_registry()
81            .accept_registration(self.state.namespace_guard(), &caller, &register, worker_tx)
82            .await
83            .map_err(|error| status_from_server_error(&error))?;
84
85        let pending = self.state.pending_activities().clone();
86        let heartbeat = self.state.heartbeat_tracker().clone();
87        let drain = self.state.drain_state().clone();
88        let registry = self.state.worker_registry().clone();
89        let liveness_waiters = self.state.grpc_liveness_waiters().clone();
90        let worker_id = registration
91            .worker_id()
92            .ok_or_else(|| Status::internal("worker registration missing id"))?;
93        heartbeat
94            .register_connection(worker_id, std::time::Instant::now())
95            .map_err(|error| status_from_server_error(&error))?;
96        // A worker serves a SET of namespaces; the ack echoes them joined in
97        // stable order purely for the worker's logs (the RegisterAck namespace
98        // field is informational, not a routing input).
99        let authorized_namespace = registration
100            .namespaces()
101            .filter(|namespaces| !namespaces.is_empty())
102            .ok_or_else(|| Status::internal("worker registration missing namespace"))?
103            .iter()
104            .cloned()
105            .collect::<Vec<_>>()
106            .join(",");
107
108        // RegisterAck ordering guarantee: the ack is enqueued on `task_tx`
109        // BEFORE the write forwarder that copies dispatched tasks onto the
110        // same channel is spawned, so no task frame can precede it on the
111        // wire. This is a structural ordering proof, not a timing hope.
112        task_tx
113            .try_send(Ok(register_ack_frame(
114                worker_id,
115                &authorized_namespace,
116                heartbeat_grace,
117                recorded_capacity(&registry, worker_id)?,
118            )))
119            .map_err(|_| Status::internal("worker response channel closed before RegisterAck"))?;
120
121        tokio::spawn(async move {
122            let write_handle = spawn_write_forwarder(worker_rx, task_tx.clone());
123
124            // Armed BEFORE the inbound loop runs: the sweep in its `Drop`
125            // fires on every exit from this task — clean stream end, stream
126            // error, token expiry, even a panic unwinding `process_inbound`.
127            // The unbounded dispatch wait depends on it.
128            let teardown = StreamTeardown {
129                worker_id,
130                heartbeat: &heartbeat,
131                registry: &registry,
132                pending: &pending,
133                drain: &drain,
134                liveness_waiters: &liveness_waiters,
135            };
136            let session = WorkerSession {
137                worker_id,
138                pending: &pending,
139                heartbeat: &heartbeat,
140                registry: &registry,
141                drain: &drain,
142                token_expires_at,
143                heartbeat_grace,
144                task_tx: task_tx.clone(),
145                liveness_waiters: liveness_waiters.clone(),
146            };
147            if let Err(status) = process_inbound(inbound, session).await {
148                tracing::info!(
149                    worker_id = ?worker_id,
150                    %status,
151                    "worker stream closed with status"
152                );
153            }
154
155            write_handle.abort();
156            drop(task_tx);
157            drop(teardown);
158            // The teardown sweep already deregistered the stream; consuming
159            // the registration here is an idempotent no-op that still
160            // surfaces a poisoned-lock error loudly.
161            if let Err(error) = registration.deregister() {
162                tracing::error!(
163                    worker_id = ?worker_id,
164                    %error,
165                    "worker deregistration failed during stream teardown"
166                );
167            }
168        });
169
170        Ok(Response::new(ReceiverStream::new(task_rx)))
171    }
172}
173
174/// Spawn the write forwarder: it copies registry-delivered [`WorkerMessage`]s
175/// onto the worker's response stream, and — the #176 zombie fix — TERMINATES
176/// the RPC when the registry drops this worker's delivery sender.
177///
178/// `recv` returning `None` means every delivery sender is gone: the worker
179/// was DEREGISTERED while its stream stayed open (the heartbeat expiry sweep,
180/// or any future administrative removal). Silently deregistering would leave
181/// the worker a zombie — connected but unroutable, believing it is
182/// registered, its heartbeats rejected as "not in flight", never
183/// re-registering until its own stream happens to end. Ending the RPC with a
184/// retryable `Unavailable` status makes the worker OBSERVE the
185/// deregistration and re-register through its reconnect machinery. (On the
186/// normal teardown path this task is aborted before the registration is
187/// consumed, so the status is never sent to a worker that hung up; a send
188/// failure means the response stream's consumer is already gone, so there is
189/// no one left to signal.)
190///
191/// # The status names the EVENT, never a cause this task cannot see
192///
193/// It used to say "heartbeat window expired", and that was a guess dressed as
194/// a finding. This task observes one thing — the registry dropped this
195/// worker's sender — and a registry can drop a sender for several reasons: an
196/// administrative removal, a drain, a failed delivery, and the expiry sweep.
197/// Naming one of them here made the server assert a cause for its own
198/// decision, and the [`StreamTeardown`] guard then tore the stream down and
199/// read the torn-down stream back as corroboration for it.
200///
201/// That mattered most in exactly the case this landing removes. A worker
202/// running at its full concurrency was reaped for silence, told on the way out
203/// that its heartbeat window had expired, and reconnected believing it had
204/// missed beats it had in fact been sending all along. The reap itself is gone
205/// now — a connected worker holding work is no longer deregistered for silence
206/// — and the parenthetical goes with it: what remains still names the
207/// deregistration, because that IS what this task observed, and directs the
208/// reader to the log line that carries the reason, which is written by the path
209/// that actually knows it. The word the wording contract in
210/// `tests/heartbeat_sweeper_e2e.rs` pins is the fact; the cause was never this
211/// task's to state.
212fn spawn_write_forwarder(
213    mut worker_rx: mpsc::Receiver<WorkerMessage>,
214    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
215) -> tokio::task::JoinHandle<()> {
216    tokio::spawn(async move {
217        while let Some(message) = worker_rx.recv().await {
218            let msg = encode_server_to_worker(message);
219            if task_tx.send(Ok(msg)).await.is_err() {
220                return;
221            }
222        }
223        let _ = task_tx
224            .send(Err(Status::unavailable(
225                "worker was deregistered by the server; reconnect and re-register. The \
226                 server-side log line for this worker id carries the reason — this stream only \
227                 observed that its delivery channel was dropped",
228            )))
229            .await;
230    })
231}
232
233/// Drop guard that sweeps a torn-down worker stream's in-flight activities:
234/// failed back to the engine mid-run, or parked for restart recovery under a
235/// graceful drain (#207) — see [`teardown_worker_stream`].
236///
237/// A guard rather than a call site so the sweep cannot be skipped by any
238/// exit from the stream task — including a panic unwinding the inbound
239/// loop, which would otherwise leave every dispatch blocked on that worker
240/// waiting forever.
241struct StreamTeardown<'a> {
242    worker_id: WorkerId,
243    heartbeat: &'a crate::worker::HeartbeatTracker,
244    registry: &'a crate::worker::ConnectedWorkerRegistry,
245    pending: &'a PendingActivities,
246    drain: &'a crate::shutdown::DrainState,
247    /// The probe's answer-correlation registry (#197), released here for the
248    /// same reason the sweep runs here: a guard fires on EVERY exit from the
249    /// stream task, a plain call after the loop does not.
250    liveness_waiters: &'a crate::worker::GrpcLivenessWaiters,
251}
252
253impl Drop for StreamTeardown<'_> {
254    fn drop(&mut self) {
255        teardown_worker_stream(
256            self.worker_id,
257            self.heartbeat,
258            self.registry,
259            self.pending,
260            self.drain,
261        );
262        // #197: this worker can no longer answer anything. Inside the guard
263        // rather than after the inbound loop, because a panic unwinding
264        // `process_inbound` would skip a plain call and leak the armed waiter
265        // for the life of the process — one entry per panicking stream on a
266        // server built never to die. It changes no verdict: the probe's own
267        // cadence judges an unanswered ping either way. It only stops the leak,
268        // and stops a re-registered worker id inheriting a sequence from a
269        // stream that is gone.
270        if let Err(error) = self.liveness_waiters.disarm(self.worker_id) {
271            tracing::error!(
272                worker_id = ?self.worker_id,
273                %error,
274                "gRPC liveness waiter map is poisoned; armed pings for departed workers can no \
275                 longer be released"
276            );
277        }
278    }
279}
280
281/// Sweep a torn-down worker stream's in-flight activities.
282///
283/// The stream is the worker's liveness. When it ends mid-run — process death,
284/// network disconnect, expired token — every activity still assigned to this
285/// worker must be failed back through the completion sink as a retryable
286/// lost-worker error. The activity dispatch wait is unbounded by design (the
287/// engine imposes no activity timeout), so this sweep is what unblocks
288/// dispatches whose worker died mid-activity; the engine's retry policy then
289/// decides re-dispatch.
290///
291/// Under a graceful drain (#207) the stream ending is the EXPECTED worker
292/// response to the drain request, not a death: the worker's in-flight tasks
293/// are PARKED for restart recovery instead — nothing is recorded, nothing is
294/// delivered, the durable log keeps its dangling scheduled/started trail
295/// exactly as a kill -9 would, and post-restart replay re-dispatches it. The
296/// park-vs-fail branch keys on
297/// [`DrainState::is_draining`](crate::shutdown::DrainState::is_draining): a
298/// worker lost while the server is NOT draining still fails-and-retries
299/// byte-identically to before.
300fn teardown_worker_stream(
301    worker_id: WorkerId,
302    heartbeat: &crate::worker::HeartbeatTracker,
303    registry: &crate::worker::ConnectedWorkerRegistry,
304    pending: &PendingActivities,
305    drain: &crate::shutdown::DrainState,
306) {
307    if drain.is_draining() {
308        match heartbeat.park_disconnected_worker(worker_id, registry, pending) {
309            Ok(report) if report.tasks.is_empty() => {}
310            Ok(report) => {
311                tracing::info!(
312                    worker_id = ?worker_id,
313                    parked_tasks = report.tasks.len(),
314                    "worker stream ended during drain; in-flight activities \
315                     parked for restart recovery"
316                );
317            }
318            Err(error) => {
319                tracing::error!(
320                    worker_id = ?worker_id,
321                    %error,
322                    "failed to park draining worker's in-flight activities"
323                );
324            }
325        }
326    } else {
327        match heartbeat.fail_disconnected_worker(worker_id, registry, pending) {
328            Ok(report) if report.tasks.is_empty() => {}
329            Ok(report) => {
330                tracing::warn!(
331                    worker_id = ?worker_id,
332                    failed_tasks = report.tasks.len(),
333                    "worker disconnected with in-flight activities; \
334                     surfaced as transport losses, to be re-dispatched \
335                     attempt-neutrally"
336                );
337            }
338            Err(error) => {
339                tracing::error!(
340                    worker_id = ?worker_id,
341                    %error,
342                    "failed to sweep disconnected worker's in-flight activities"
343                );
344            }
345        }
346    }
347    // In-flight accounting may have just reached zero; wake any drain
348    // waiter so shutdown does not sit out its full timeout.
349    drain.notify_activity_drained();
350}
351
352struct WorkerSession<'a> {
353    worker_id: WorkerId,
354    pending: &'a PendingActivities,
355    heartbeat: &'a crate::worker::HeartbeatTracker,
356    /// The connected-worker registry, held because clearing an in-flight task
357    /// also frees a dispatch slot: the tracker's record and the count selection
358    /// reads are written by ONE call, which takes this.
359    registry: &'a crate::worker::ConnectedWorkerRegistry,
360    drain: &'a crate::shutdown::DrainState,
361    token_expires_at: Option<u64>,
362    heartbeat_grace: std::time::Duration,
363    task_tx: mpsc::Sender<Result<generated::ServerToWorker, Status>>,
364    /// The liveness probe's answer-correlation registry (#197). The probe
365    /// pushes pings from its own timer loop and cannot see this stream; this
366    /// handle is the only thing the two halves share.
367    liveness_waiters: crate::worker::GrpcLivenessWaiters,
368}
369
370/// WA-010 R3: a worker that answers faster than its lease lands waits here,
371/// on the async side, so the completion fence never accepts a terminal ahead
372/// of the lease it must follow. A poisoned gate is said and the completion
373/// still goes through — the result is the worker's truth and must not be lost
374/// to ordering.
375async fn await_lease_before_completion(
376    session: &WorkerSession<'_>,
377    completion: &ActivityCompletion,
378) {
379    if let Err(error) = session
380        .pending
381        .completion_fences()
382        .lease_settled(&completion.completion_token)
383        .await
384    {
385        tracing::error!(
386            worker_id = ?session.worker_id,
387            workflow_id = %completion.workflow_id,
388            activity_id = %completion.activity_id,
389            %error,
390            "lease gate unreadable; accepting the completion without waiting for its lease"
391        );
392    }
393}
394
395async fn process_inbound(
396    mut inbound: Streaming<generated::WorkerToServer>,
397    session: WorkerSession<'_>,
398) -> Result<(), Status> {
399    let mut expired_since: Option<std::time::Instant> = None;
400    while let Some(msg) = inbound.message().await? {
401        refresh_connection_lease(&session)?;
402        let Some(inner) = msg.message else {
403            continue;
404        };
405        match inner {
406            generated::worker_to_server::Message::Result(result) => {
407                let proto_result = decode_activity_result(result);
408                match ActivityCompletion::try_from(proto_result) {
409                    Ok(completion) => {
410                        let workflow_id = completion.workflow_id.clone();
411                        let activity_id = completion.activity_id.clone();
412                        await_lease_before_completion(&session, &completion).await;
413                        let after_accept = || {
414                            // Fail open: the tracking clear is bookkeeping.
415                            // The ack below is sent regardless, so a withheld
416                            // result is dropped by the worker forever, and the
417                            // lost-worker sweep cannot recover it — it reads
418                            // the same poisoned lock.
419                            let _ = crate::worker::bridge::clear_completed_task_tracking(
420                                session.heartbeat,
421                                session.registry,
422                                session.worker_id,
423                                &workflow_id,
424                                &activity_id,
425                            );
426                            session.drain.notify_activity_drained();
427                            Ok(())
428                        };
429                        if let Err(error) = session
430                            .pending
431                            .complete_activity_after_accept(completion, after_accept)
432                        {
433                            // The only error source here is the execution-
434                            // generation fence (the tracking clear fails open
435                            // above). Leave the current worker's liveness
436                            // entry intact when the generation proof is wrong.
437                            // Its stopped heartbeat pump will drive the normal
438                            // loss/retry path.
439                            tracing::error!(
440                                worker_id = ?session.worker_id,
441                                workflow_id = %workflow_id,
442                                activity_id = %activity_id,
443                                %error,
444                                "activity completion rejected by execution-generation proof"
445                            );
446                        }
447                        // Ack every well-formed result frame — including
448                        // duplicates with no pending waiter; their re-report
449                        // obligation is equally discharged. `try_send`: a
450                        // worker that stopped draining its receive side must
451                        // not wedge the inbound loop; a dropped ack is
452                        // recovered by the next-session re-report.
453                        let ack = result_ack_frame(&workflow_id, &activity_id);
454                        if let Err(error) = session.task_tx.try_send(Ok(ack)) {
455                            tracing::warn!(
456                                worker_id = ?session.worker_id,
457                                workflow_id = %workflow_id,
458                                activity_id = %activity_id,
459                                %error,
460                                "result ack dropped: worker stream channel unavailable"
461                            );
462                        }
463                    }
464                    Err(error) => {
465                        // Malformed result: no ids to ack with. Loud, never
466                        // silent — the worker's entry will re-report and
467                        // re-fail visibly each session.
468                        tracing::error!(
469                            worker_id = ?session.worker_id,
470                            %error,
471                            "malformed activity result frame; no ack sent"
472                        );
473                    }
474                }
475            }
476            generated::worker_to_server::Message::Register(_) => {
477                warn_duplicate_registration(session.worker_id);
478            }
479            // The worker handed one dispatch back for want of a free slot. NOT
480            // a result and NOT a failure: the activity never ran, so this is a
481            // transport-domain non-start that re-parks the dispatch clock-free,
482            // corrects this server's per-worker count, and lets selection run
483            // again. No attempt is consumed and nothing is recorded against the
484            // action's authored budget.
485            //
486            // The tracking is cleared FIRST, because that is the correction:
487            // the count that let the server push past this worker's admission
488            // is the same count selection will read on the very next pass, and
489            // re-selecting before correcting it would choose the same full
490            // worker again.
491            generated::worker_to_server::Message::TaskRefused(refusal) => {
492                handle_task_refusal(&session, refusal);
493            }
494            // #197: the answer to a transport liveness ping. It proves the one
495            // fact dispatch eligibility is gated on — that the server reached
496            // this worker's dispatch path — but it proves it only through the
497            // probe, which is the party that knows which sequence it asked.
498            // Nothing is recorded here beyond handing the echo across.
499            generated::worker_to_server::Message::LivenessAnswer(answer) => {
500                deliver_liveness_answer(&session, answer.liveness_ping);
501            }
502            generated::worker_to_server::Message::Heartbeat(heartbeat_msg) => {
503                // Empty task ids are the connection-level lease beat. The frame
504                // already advanced the lease above; it intentionally has no
505                // per-task liveness entry to update.
506                if heartbeat_msg.workflow_id.is_none() && heartbeat_msg.activity_id.is_none() {
507                    continue;
508                }
509                if let Err(error) = session.heartbeat.record_heartbeat(
510                    session.worker_id,
511                    decode_heartbeat(heartbeat_msg),
512                    std::time::Instant::now(),
513                ) {
514                    // Malformed frames and heartbeats for untracked tasks
515                    // are worker-side defects worth surfacing; a poisoned
516                    // tracker lock is a server-side corruption signal that
517                    // must never vanish silently.
518                    if matches!(error, crate::ServerError::LockPoisoned { .. }) {
519                        tracing::error!(
520                            worker_id = ?session.worker_id,
521                            %error,
522                            "heartbeat tracker lock poisoned; liveness state untrustworthy"
523                        );
524                    } else {
525                        tracing::warn!(
526                            worker_id = ?session.worker_id,
527                            %error,
528                            "worker heartbeat rejected"
529                        );
530                    }
531                }
532                enforce_token_expiration(&session, &mut expired_since).await?;
533            }
534        }
535    }
536    Ok(())
537}
538
539/// Hand one `LivenessAnswer`'s echoed sequence to the probe's correlation
540/// registry (#197).
541///
542/// Never silent. An unmatched answer is a real fact about this link — the
543/// worker replied after the probe's cadence had already expired, or echoed a
544/// sequence that was not asked — and it is exactly the evidence an operator
545/// needs when a worker keeps failing to clear its probation while looking
546/// healthy from the outside. A poisoned waiter map is louder still: it means
547/// no gRPC worker on this server can clear probation at all.
548fn deliver_liveness_answer(session: &WorkerSession<'_>, sequence: u64) {
549    match session.liveness_waiters.answer(session.worker_id, sequence) {
550        Ok(true) => {}
551        Ok(false) => tracing::warn!(
552            worker_id = ?session.worker_id,
553            liveness_ping = sequence,
554            "worker answered a liveness ping the server was no longer waiting for; the answer \
555             arrived after its probe cadence expired, or echoed a sequence that was never asked. \
556             It banks NOTHING toward the dispatch probation"
557        ),
558        Err(error) => tracing::error!(
559            worker_id = ?session.worker_id,
560            liveness_ping = sequence,
561            %error,
562            "gRPC liveness waiter map is poisoned; no gRPC worker on this server can clear its \
563             dispatch probation until the process is restarted"
564        ),
565    }
566}
567
568fn refresh_connection_lease(session: &WorkerSession<'_>) -> Result<(), Status> {
569    session
570        .heartbeat
571        .record_connection_activity(session.worker_id, std::time::Instant::now())
572        .map(|_| ())
573        .map_err(|error| {
574            tracing::error!(
575                worker_id = ?session.worker_id,
576                %error,
577                "failed to advance worker connection lease"
578            );
579            status_from_server_error(&error)
580        })
581}
582
583fn warn_duplicate_registration(worker_id: WorkerId) {
584    tracing::warn!(
585        worker_id = ?worker_id,
586        "ignoring subsequent RegisterWorker message; \
587         only the first registration is accepted per stream"
588    );
589}
590
591async fn enforce_token_expiration(
592    session: &WorkerSession<'_>,
593    expired_since: &mut Option<std::time::Instant>,
594) -> Result<(), Status> {
595    if !token_expired(session.token_expires_at) {
596        return Ok(());
597    }
598    let first_expired = *expired_since.get_or_insert_with(std::time::Instant::now);
599    let _ = session
600        .task_tx
601        .send(Err(Status::unauthenticated(
602            "worker token expired; re-authentication required",
603        )))
604        .await;
605    if first_expired.elapsed() >= session.heartbeat_grace {
606        return Err(Status::unauthenticated("worker token expired"));
607    }
608    Ok(())
609}
610
611async fn worker_caller_from_metadata(
612    metadata: &tonic::metadata::MetadataMap,
613    state: &ServerState,
614) -> Result<CallerIdentity, Status> {
615    crate::api::grpc::caller_from_metadata(metadata, state).await
616}
617
618async fn token_expiration_from_metadata(
619    metadata: &tonic::metadata::MetadataMap,
620    state: &ServerState,
621) -> Result<Option<u64>, Status> {
622    if !state.runtime_config().auth.enabled {
623        return Ok(None);
624    }
625    #[cfg(feature = "auth")]
626    {
627        let bearer = metadata
628            .get("authorization")
629            .and_then(|value| value.to_str().ok())
630            .and_then(parse_bearer)
631            .ok_or_else(|| Status::unauthenticated("missing bearer token"))?;
632        let Some(cache) = state.jwks_cache() else {
633            return Err(Status::unauthenticated("invalid bearer token"));
634        };
635        return cache
636            .validate(&bearer)
637            .await
638            .map(|claims| Some(claims.expires_at()))
639            .map_err(|_error| Status::unauthenticated("invalid bearer token"));
640    }
641    #[cfg(not(feature = "auth"))]
642    {
643        let _ = metadata;
644        // Yield to preserve the async signature required by the auth-feature branch.
645        tokio::task::yield_now().await;
646        Ok(None)
647    }
648}
649
650#[cfg(feature = "auth")]
651fn parse_bearer(value: &str) -> Option<String> {
652    let token = value.strip_prefix("Bearer ")?.trim();
653    if token.is_empty() {
654        return None;
655    }
656    Some(token.to_owned())
657}
658
659fn token_expired(expires_at: Option<u64>) -> bool {
660    expires_at.is_some_and(|expires_at| {
661        #[cfg(feature = "auth")]
662        {
663            crate::auth::jwks::is_expired(expires_at)
664        }
665        #[cfg(not(feature = "auth"))]
666        {
667            let _ = expires_at;
668            false
669        }
670    })
671}
672
673fn status_from_server_error(error: &crate::ServerError) -> Status {
674    let wire = error.to_wire_error();
675    if wire.code == aion_proto::WireErrorCode::NamespaceDenied {
676        Status::permission_denied(wire.message)
677    } else {
678        Status::internal(wire.message)
679    }
680}
681
682/// Build the per-result acknowledgement frame for a consumed `ActivityResult`.
683fn result_ack_frame(
684    workflow_id: &aion_core::WorkflowId,
685    activity_id: &aion_core::ActivityId,
686) -> generated::ServerToWorker {
687    generated::ServerToWorker {
688        message: Some(generated::server_to_worker::Message::ResultAck(
689            generated::ResultAck {
690                workflow_id: Some(generated::WorkflowId {
691                    uuid: workflow_id.to_string(),
692                }),
693                activity_id: Some(generated::ActivityId {
694                    sequence_position: activity_id.sequence_position(),
695                }),
696            },
697        )),
698    }
699}
700
701fn decode_register(r: generated::RegisterWorker) -> ProtoRegisterWorker {
702    ProtoRegisterWorker {
703        namespaces: r.namespaces,
704        activity_types: r.activity_types,
705        task_queue: r.task_queue,
706        node: r.node,
707        activities: r
708            .activities
709            .into_iter()
710            .map(|activity| ProtoActivityDescriptor {
711                name: activity.name,
712                input_schema_json: activity.input_schema_json,
713                output_schema_json: activity.output_schema_json,
714            })
715            .collect(),
716        identity: r.identity,
717        instance: r.instance.map(|instance| ProtoWorkerInstanceIdentity {
718            deployment: instance.deployment,
719            instance_id: instance.instance_id,
720        }),
721        // Carried through with its PRESENCE intact: absent and zero are
722        // different registrations and the admission gate refuses them with
723        // different words. Collapsing them here would put the server back in
724        // the business of inventing a capacity for a worker that did not state
725        // one.
726        max_concurrency: r.max_concurrency,
727    }
728}
729
730fn validate_worker_contracts(
731    state: &ServerState,
732    register: &ProtoRegisterWorker,
733) -> Result<(), Status> {
734    let advertised = register
735        .activities
736        .iter()
737        .map(|activity| {
738            let input_schema =
739                serde_json::from_str(&activity.input_schema_json).map_err(|error| {
740                    Status::invalid_argument(format!(
741                        "worker activity `{}` input_schema_json is invalid: {error}",
742                        activity.name
743                    ))
744                })?;
745            let output_schema =
746                serde_json::from_str(&activity.output_schema_json).map_err(|error| {
747                    Status::invalid_argument(format!(
748                        "worker activity `{}` output_schema_json is invalid: {error}",
749                        activity.name
750                    ))
751                })?;
752            Ok(aion_package::ActivityDescriptor {
753                name: activity.name.clone(),
754                input_schema,
755                output_schema,
756            })
757        })
758        .collect::<Result<Vec<_>, Status>>()?;
759    // Mirrors the liminal transport's no-catalog admission: a state built
760    // from parts without an engine handle has no durable catalog, so no
761    // deployed `.v4` contract can exist to contradict this worker. Refusing
762    // here would turn every registration away forever on such a server; the
763    // strict field-level check applies on every engine-backed state.
764    let Ok(engine) = state.engine() else {
765        tracing::warn!(
766            task_queue = %register.task_queue,
767            identity = %register.identity,
768            "worker contract check skipped: server state has no engine handle, \
769             so no deployed contracts exist to check against"
770        );
771        return Ok(());
772    };
773    // Both advertised forms travel into the gate together — see
774    // [`crate::worker::contracts::WorkerAdvertisement`] for why carrying only
775    // one of them makes a refusal contradict itself.
776    let activity_types = register
777        .activity_types
778        .iter()
779        .cloned()
780        .collect::<std::collections::BTreeSet<_>>();
781    crate::worker::contracts::validate_worker_contracts(
782        &engine,
783        state.worker_registry().admission_audit(),
784        &register.task_queue,
785        crate::worker::registry::optional_node(&register.node).as_deref(),
786        &register.identity,
787        crate::worker::contracts::WorkerAdvertisement {
788            activity_types: &activity_types,
789            contracts: &advertised,
790        },
791    )
792    .map_err(|error| match error {
793        crate::worker::contracts::ContractAdmissionError::Mismatch { .. } => {
794            Status::failed_precondition(error.to_string())
795        }
796        crate::worker::contracts::ContractAdmissionError::Catalog { .. } => {
797            Status::internal(error.to_string())
798        }
799    })
800}
801
802fn encode_server_to_worker(message: WorkerMessage) -> generated::ServerToWorker {
803    let message = match message {
804        WorkerMessage::ActivityTask(task) => {
805            generated::server_to_worker::Message::Task(encode_task(*task))
806        }
807        WorkerMessage::DrainRequest => {
808            generated::server_to_worker::Message::Drain(generated::DrainRequest {})
809        }
810        // #197: the liveness ping rides the SAME forwarder a dispatch rides,
811        // deliberately — that is the whole point of it. Encoding it anywhere
812        // else would measure a channel no dispatch travels.
813        WorkerMessage::LivenessPing(ping) => {
814            generated::server_to_worker::Message::LivenessPing(generated::LivenessPing {
815                liveness_ping: ping.liveness_ping,
816                silence_window_ms: ping.silence_window_ms,
817            })
818        }
819        // #233: the cancel rides the SAME forwarder the dispatch rode, so it
820        // cannot overtake the task it interrupts and a worker whose dispatch
821        // path is dead cannot appear to have been told.
822        WorkerMessage::CancelActivity(cancel) => {
823            generated::server_to_worker::Message::CancelActivity(generated::CancelActivity {
824                workflow_id: cancel
825                    .workflow_id
826                    .map(|id| generated::WorkflowId { uuid: id.uuid }),
827                activity_id: cancel.activity_id.map(|id| generated::ActivityId {
828                    sequence_position: id.sequence_position,
829                }),
830            })
831        }
832    };
833    generated::ServerToWorker {
834        message: Some(message),
835    }
836}
837
838fn encode_task(task: aion_proto::ProtoActivityTask) -> generated::ActivityTask {
839    generated::ActivityTask {
840        workflow_id: task
841            .workflow_id
842            .map(|id| generated::WorkflowId { uuid: id.uuid }),
843        activity_id: task.activity_id.map(|id| generated::ActivityId {
844            sequence_position: id.sequence_position,
845        }),
846        activity_type: task.activity_type,
847        input: task.input.map(|p| generated::Payload {
848            content_type: p.content_type,
849            bytes: p.bytes,
850        }),
851        attempt: task.attempt,
852        labels: task.labels,
853        run_id: task.run_id.map(|id| generated::RunId { uuid: id.uuid }),
854        completion_token: task.completion_token,
855        idempotency_key: task.idempotency_key,
856    }
857}
858
859fn decode_activity_result(r: generated::ActivityResult) -> ProtoActivityResult {
860    ProtoActivityResult {
861        workflow_id: r
862            .workflow_id
863            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
864        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
865            sequence_position: id.sequence_position,
866        }),
867        outcome: r.outcome.map(decode_outcome),
868        run_id: r.run_id.map(|id| aion_proto::ProtoRunId { uuid: id.uuid }),
869        completion_token: r.completion_token,
870    }
871}
872
873fn decode_heartbeat(r: generated::Heartbeat) -> aion_proto::ProtoHeartbeat {
874    aion_proto::ProtoHeartbeat {
875        workflow_id: r
876            .workflow_id
877            .map(|id| aion_proto::ProtoWorkflowId { uuid: id.uuid }),
878        activity_id: r.activity_id.map(|id| aion_proto::ProtoActivityId {
879            sequence_position: id.sequence_position,
880        }),
881        progress: r.progress.map(|p| aion_proto::ProtoPayload {
882            content_type: p.content_type,
883            bytes: p.bytes,
884        }),
885    }
886}
887
888fn decode_outcome(
889    outcome: generated::activity_result::Outcome,
890) -> aion_proto::proto_activity_result::Outcome {
891    match outcome {
892        generated::activity_result::Outcome::Result(p) => {
893            aion_proto::proto_activity_result::Outcome::Result(aion_proto::ProtoPayload {
894                content_type: p.content_type,
895                bytes: p.bytes,
896            })
897        }
898        generated::activity_result::Outcome::Error(e) => {
899            aion_proto::proto_activity_result::Outcome::Error(aion_proto::ProtoActivityError {
900                kind: e.kind,
901                message: e.message,
902                details: e.details.map(|p| aion_proto::ProtoPayload {
903                    content_type: p.content_type,
904                    bytes: p.bytes,
905                }),
906            })
907        }
908    }
909}
910
911#[cfg(test)]
912mod tests {
913    use std::time::{Duration, Instant};
914
915    use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
916
917    use crate::shutdown::DrainState;
918    use crate::worker::dispatch::{
919        ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
920    };
921    use crate::worker::heartbeat::InFlightActivity;
922    use crate::worker::registry::ConnectedWorkerRegistry;
923    use crate::worker::{HeartbeatTracker, PendingActivities};
924
925    use super::{
926        WorkerSession, decode_register, generated, handle_task_refusal, teardown_worker_stream,
927    };
928
929    type TestError = Box<dyn std::error::Error>;
930
931    #[test]
932    fn decode_register_maps_tag_seven_instance_without_changing_absent_registration() {
933        let generated = super::generated::RegisterWorker {
934            namespaces: vec!["orders".to_owned()],
935            activity_types: vec!["shell".to_owned()],
936            task_queue: "shell".to_owned(),
937            node: "node-a".to_owned(),
938            activities: Vec::new(),
939            identity: "build-a".to_owned(),
940            instance: Some(super::generated::WorkerInstanceIdentity {
941                deployment: "shells".to_owned(),
942                instance_id: "instance-1".to_owned(),
943            }),
944            max_concurrency: Some(4),
945        };
946        let mapped = decode_register(generated.clone());
947        let instance = mapped.instance.as_ref();
948        assert_eq!(
949            instance.map(|value| value.deployment.as_str()),
950            Some("shells")
951        );
952        assert_eq!(
953            instance.map(|value| value.instance_id.as_str()),
954            Some("instance-1")
955        );
956
957        let mut absent = generated;
958        absent.instance = None;
959        let mapped_absent = decode_register(absent);
960        assert!(mapped_absent.instance.is_none());
961        assert_eq!(mapped_absent.identity, "build-a");
962    }
963
964    /// One tracked in-flight dispatch with a live pending waiter, ready for a
965    /// stream teardown: the registered worker, the shared tracker/pending/drain
966    /// state, and the waiter's receiver.
967    struct TeardownFixture {
968        registry: ConnectedWorkerRegistry,
969        tracker: HeartbeatTracker,
970        pending: PendingActivities,
971        drain: DrainState,
972        worker_id: crate::worker::registry::WorkerId,
973        workflow_id: WorkflowId,
974        /// The concrete run this dispatch belongs to. Held so a test can mint a
975        /// SIBLING authorization for the same attempt of the same execution
976        /// generation, which is what an at-least-once redelivery is.
977        run_id: RunId,
978        activity_id: ActivityId,
979        completion_token: crate::worker::CompletionToken,
980        rx: std::sync::mpsc::Receiver<Result<String, String>>,
981        /// Held so the registered worker stays routable until the teardown
982        /// under test deregisters it (dropping the guard would race that).
983        _registration: crate::worker::registry::WorkerRegistration,
984    }
985
986    fn fixture() -> Result<TeardownFixture, TestError> {
987        let registry = ConnectedWorkerRegistry::default();
988        let (tx, _rx) = tokio::sync::mpsc::channel(1);
989        let activity_types = [String::from("greet")];
990        let registration = registry.register(
991            "default",
992            activity_types.iter(),
993            tx,
994            crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
995        )?;
996        let worker_id = registration
997            .worker_id()
998            .ok_or("test worker registration missing id")?;
999        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1000        let pending = PendingActivities::new(Duration::from_secs(5));
1001        let workflow_id = WorkflowId::new_v4();
1002        let run_id = RunId::new_v4();
1003        let activity_id = ActivityId::from_sequence_position(0);
1004        let (completion_token, rx, _) =
1005            pending.insert_for_test(workflow_id.clone(), &run_id, activity_id.clone(), 1)?;
1006        tracker.track_task(
1007            worker_id,
1008            InFlightActivity {
1009                workflow_id: workflow_id.clone(),
1010                activity_id: activity_id.clone(),
1011                attempt: 1,
1012                completion_token: completion_token.clone(),
1013            },
1014            Instant::now(),
1015            &registry,
1016            None,
1017        )?;
1018        Ok(TeardownFixture {
1019            registry,
1020            tracker,
1021            pending,
1022            drain: DrainState::default(),
1023            worker_id,
1024            workflow_id,
1025            run_id,
1026            activity_id,
1027            completion_token,
1028            rx,
1029            _registration: registration,
1030        })
1031    }
1032
1033    /// A `TaskRefused` frame re-parks the dispatch attempt-neutrally: it
1034    /// corrects the server's per-worker count, resolves the waiter in the
1035    /// TRANSPORT domain so the engine re-dispatches on the SAME attempt, and
1036    /// records nothing about the activity.
1037    ///
1038    /// Three properties, and each is a different way the alternative would be
1039    /// wrong. Left as a delivery failure, a busy worker's refusal would burn an
1040    /// authored retry. Left uncorrected, the count that let the server push
1041    /// past this worker's admission is the same count the next selection reads,
1042    /// so it would choose the same full worker again. Recorded as a result, an
1043    /// activity that never ran would have an outcome.
1044    #[test]
1045    fn a_task_refusal_reparks_the_dispatch_and_frees_the_slot() -> Result<(), TestError> {
1046        let fixture = fixture()?;
1047        assert_eq!(
1048            fixture.registry.in_flight_for_worker(fixture.worker_id)?,
1049            1,
1050            "the dispatch is on this worker's account before it is refused"
1051        );
1052
1053        let session = WorkerSession {
1054            worker_id: fixture.worker_id,
1055            pending: &fixture.pending,
1056            heartbeat: &fixture.tracker,
1057            registry: &fixture.registry,
1058            drain: &fixture.drain,
1059            token_expires_at: None,
1060            heartbeat_grace: Duration::from_secs(5),
1061            task_tx: tokio::sync::mpsc::channel(1).0,
1062            liveness_waiters: crate::worker::GrpcLivenessWaiters::default(),
1063        };
1064        handle_task_refusal(
1065            &session,
1066            generated::TaskRefused {
1067                workflow_id: Some(generated::WorkflowId {
1068                    uuid: fixture.workflow_id.to_string(),
1069                }),
1070                activity_id: Some(generated::ActivityId {
1071                    sequence_position: fixture.activity_id.sequence_position(),
1072                }),
1073                reason: String::from("worker is already running 4 activities"),
1074                completion_token: fixture.completion_token.as_str().to_owned(),
1075            },
1076        );
1077
1078        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1079        let reason = resolved
1080            .err()
1081            .ok_or("a refusal must not resolve as success")?;
1082        assert!(
1083            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1084            "a refusal is a TRANSPORT-domain non-start, so it must carry the class the engine              re-dispatches attempt-neutrally — not a class that spends the action's own retry              budget: {reason}"
1085        );
1086        assert!(
1087            reason.contains("refused the dispatch"),
1088            "and it must say a worker DECLINED, not that one died: nothing was lost here and an              operator sent looking for a dead worker would find a healthy one: {reason}"
1089        );
1090        assert_eq!(
1091            fixture.registry.in_flight_for_worker(fixture.worker_id)?,
1092            0,
1093            "the refusal must free the slot the server believed this worker was using, or the              very next selection reads the same full count and picks it again"
1094        );
1095        assert_eq!(
1096            fixture.tracker.in_flight_for_worker(fixture.worker_id)?,
1097            0,
1098            "the tracker's own record must go with it: an activity that is not running has no              liveness to keep"
1099        );
1100        assert!(
1101            fixture.registry.is_registered(fixture.worker_id)?,
1102            "the worker declined ONE task; it is neither gone nor at fault"
1103        );
1104        Ok(())
1105    }
1106
1107    /// A refusal from a SUPERSEDED generation is rejected by the completion
1108    /// fence, exactly as a stale result is.
1109    ///
1110    /// Without this, a late refusal from a worker whose attempt has already
1111    /// been taken over could re-park the attempt that superseded it — the
1112    /// dispatch equivalent of a zombie completion.
1113    #[test]
1114    fn a_refusal_from_a_stale_generation_is_refused() -> Result<(), TestError> {
1115        let fixture = fixture()?;
1116        let session = WorkerSession {
1117            worker_id: fixture.worker_id,
1118            pending: &fixture.pending,
1119            heartbeat: &fixture.tracker,
1120            registry: &fixture.registry,
1121            drain: &fixture.drain,
1122            token_expires_at: None,
1123            heartbeat_grace: Duration::from_secs(5),
1124            task_tx: tokio::sync::mpsc::channel(1).0,
1125            liveness_waiters: crate::worker::GrpcLivenessWaiters::default(),
1126        };
1127        // A token this execution site never issued.
1128        let stale = crate::worker::CompletionToken::from_wire(
1129            &fixture.workflow_id,
1130            &fixture.activity_id,
1131            uuid::Uuid::new_v4().to_string(),
1132        )?;
1133        handle_task_refusal(
1134            &session,
1135            generated::TaskRefused {
1136                workflow_id: Some(generated::WorkflowId {
1137                    uuid: fixture.workflow_id.to_string(),
1138                }),
1139                activity_id: Some(generated::ActivityId {
1140                    sequence_position: fixture.activity_id.sequence_position(),
1141                }),
1142                reason: String::from("worker is already running 4 activities"),
1143                completion_token: stale.as_str().to_owned(),
1144            },
1145        );
1146        assert!(
1147            fixture.rx.recv_timeout(Duration::from_millis(100)).is_err(),
1148            "a refusal carrying a superseded generation must not resolve the live attempt's              waiter"
1149        );
1150        // AND IT MUST NOT HAVE TOUCHED THE LIVE ATTEMPT'S BOOKKEEPING. This is
1151        // the half the test was missing: `clear_completed_task_tracking` is
1152        // keyed by (worker, workflow, activity) and carries no token, so run
1153        // before the fence it retired whatever attempt was tracked under that
1154        // key and gave back a slot the worker was still using — while the
1155        // assertion above passed, because the fence still refused the re-park.
1156        assert!(
1157            fixture.tracker.is_tracked(
1158                fixture.worker_id,
1159                &fixture.workflow_id,
1160                &fixture.activity_id
1161            )?,
1162            "the live attempt's liveness entry must survive a stale refusal; without it the \
1163             expiry sweep can no longer see a dispatch that is still running"
1164        );
1165        assert_eq!(
1166            fixture.registry.in_flight_for_worker(fixture.worker_id)?,
1167            1,
1168            "the live attempt's capacity slot must survive a stale refusal; releasing it \
1169             over-reports what this worker can take and lets selection push past its admission"
1170        );
1171        Ok(())
1172    }
1173
1174    /// #207: with drain begun, a stream teardown PARKS the in-flight dispatch —
1175    /// the waiter resolves with the ephemeral parked sentinel, no lost-worker
1176    /// failure is synthesized, and the tracker empties for drain accounting.
1177    #[test]
1178    fn teardown_under_drain_parks_instead_of_failing() -> Result<(), TestError> {
1179        let fixture = fixture()?;
1180        assert!(fixture.drain.begin());
1181
1182        teardown_worker_stream(
1183            fixture.worker_id,
1184            &fixture.tracker,
1185            &fixture.registry,
1186            &fixture.pending,
1187            &fixture.drain,
1188        );
1189
1190        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1191        assert_eq!(
1192            resolved,
1193            Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
1194            "a drain teardown must resolve the waiter with the parked sentinel"
1195        );
1196        assert_eq!(fixture.tracker.in_flight_count()?, 0);
1197        assert!(
1198            !fixture.tracker.is_tracked(
1199                fixture.worker_id,
1200                &fixture.workflow_id,
1201                &fixture.activity_id
1202            )?,
1203            "parking must retire the tracked entry"
1204        );
1205        Ok(())
1206    }
1207
1208    /// Regression pin: WITHOUT drain, the teardown path still resolves the
1209    /// waiter with a lost-worker failure — but in the TRANSPORT domain, not the
1210    /// action's.
1211    ///
1212    /// The old contract here was `retryable:`, and that framing was the Hit D
1213    /// defect: an authored retry policy governs how often the ACTION may fail,
1214    /// and an activity with no policy (the SDK default) turned a worker death
1215    /// into a TERMINAL failure. Worker loss now carries the `lost:` class, which
1216    /// the engine re-dispatches attempt-neutrally.
1217    #[test]
1218    fn teardown_without_drain_fails_with_the_transport_domain_lost_worker_class()
1219    -> Result<(), TestError> {
1220        let fixture = fixture()?;
1221
1222        teardown_worker_stream(
1223            fixture.worker_id,
1224            &fixture.tracker,
1225            &fixture.registry,
1226            &fixture.pending,
1227            &fixture.drain,
1228        );
1229
1230        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1231        let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1232        assert!(
1233            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1234            "a mid-run teardown must surface the TRANSPORT-domain loss class, never the \
1235             action's retry vocabulary: {reason}"
1236        );
1237        assert!(
1238            reason.contains("lost before reporting activity result"),
1239            "the failure must name worker loss: {reason}"
1240        );
1241        assert_eq!(fixture.tracker.in_flight_count()?, 0);
1242        Ok(())
1243    }
1244
1245    /// R2 fencing red: after heartbeat loss resolves worker A's attempt and a
1246    /// retry installs worker B's waiter for the same activity, A's late result
1247    /// must not resolve B's generation.
1248    #[test]
1249    fn stale_worker_completion_after_heartbeat_loss_does_not_resolve_retry() -> Result<(), TestError>
1250    {
1251        let fixture = fixture()?;
1252        teardown_worker_stream(
1253            fixture.worker_id,
1254            &fixture.tracker,
1255            &fixture.registry,
1256            &fixture.pending,
1257            &fixture.drain,
1258        );
1259        let first = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1260        assert!(
1261            first
1262                .err()
1263                .is_some_and(|reason| reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX)),
1264            "worker A loss must release attempt 1 in the transport-loss class"
1265        );
1266
1267        // A genuine RETRY: the loss released attempt 1, so worker B is serving
1268        // attempt 2 of the same run.
1269        let (retry_token, retry_rx, _) = fixture.pending.insert_for_test(
1270            fixture.workflow_id.clone(),
1271            &fixture.run_id,
1272            fixture.activity_id.clone(),
1273            2,
1274        )?;
1275        let rejected = fixture.pending.complete_activity(ActivityCompletion {
1276            workflow_id: fixture.workflow_id,
1277            activity_id: fixture.activity_id,
1278            run_id: None,
1279            completion_token: fixture.completion_token,
1280            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1281                ContentType::Json,
1282                br#"{"worker":"A","stale":true}"#.to_vec(),
1283            )),
1284        });
1285
1286        assert!(matches!(
1287            rejected,
1288            Err(crate::ServerError::ActivityCompletionRejected { .. })
1289        ));
1290        drop(retry_token);
1291        assert!(
1292            retry_rx.recv_timeout(Duration::from_millis(50)).is_err(),
1293            "worker A's late completion must be rejected instead of resolving worker B's retry"
1294        );
1295        Ok(())
1296    }
1297
1298    /// FENCE-1 §3.4, ruled and pinned: a `WorkerLost` completion consumes EVERY
1299    /// outstanding token of the attempt it names, a redelivery's sibling
1300    /// included.
1301    ///
1302    /// `HeartbeatTracker`'s loss sweep synthesizes the completion carrying the
1303    /// token it tracked for the worker it declared dead, and that completion
1304    /// goes through the same `accept`. When a redelivery of the SAME attempt has
1305    /// put a sibling authorization in a second worker's hands, the loss verdict
1306    /// retires the whole generation and the second worker's later result is
1307    /// refused. This is the ruled shape behaving as ruled — recorded here as a
1308    /// decision rather than left to be discovered.
1309    ///
1310    /// It is deliberately better than the base rather than merely different: the
1311    /// loss resolves in the TRANSPORT domain (`lost:`), which is attempt-neutral
1312    /// and re-dispatches the SAME attempt, so the work is re-done. Under the
1313    /// base this same interleaving left the workflow waiting forever with
1314    /// nothing that could retry it.
1315    #[test]
1316    fn a_worker_loss_consumes_the_sibling_token_of_a_redelivered_attempt() -> Result<(), TestError>
1317    {
1318        let fixture = fixture()?;
1319        // The redelivery: a SECOND authorization for the same attempt of the
1320        // same run, exactly as the push path mints one when a parked dispatch is
1321        // re-offered to a restored queue while the first worker still executes.
1322        let sibling = fixture.pending.completion_fences().issue(
1323            &fixture.workflow_id,
1324            &fixture.run_id,
1325            &fixture.activity_id,
1326            1,
1327        )?;
1328
1329        teardown_worker_stream(
1330            fixture.worker_id,
1331            &fixture.tracker,
1332            &fixture.registry,
1333            &fixture.pending,
1334            &fixture.drain,
1335        );
1336
1337        let resolved = fixture.rx.recv_timeout(Duration::from_millis(200))?;
1338        let reason = resolved.err().ok_or("expected a lost-worker failure")?;
1339        assert!(
1340            reason.starts_with(crate::worker::WORKER_LOST_REASON_PREFIX),
1341            "the loss must resolve in the transport domain, which re-dispatches the same attempt \
1342             rather than faulting the action: {reason}"
1343        );
1344
1345        let refused = fixture.pending.complete_activity(ActivityCompletion {
1346            workflow_id: fixture.workflow_id.clone(),
1347            activity_id: fixture.activity_id.clone(),
1348            run_id: None,
1349            completion_token: sibling,
1350            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1351                ContentType::Json,
1352                br#"{"worker":"B"}"#.to_vec(),
1353            )),
1354        });
1355        assert!(
1356            matches!(
1357                refused,
1358                Err(crate::ServerError::ActivityCompletionRejected {
1359                    reason: crate::error::CompletionRejectionReason::NoCurrentGeneration,
1360                    ..
1361                })
1362            ),
1363            "the first accepted completion — here the loss verdict — consumes the whole \
1364             generation, so the sibling worker's result is the duplicate: {refused:?}"
1365        );
1366        Ok(())
1367    }
1368}