Skip to main content

aion_server/worker/
heartbeat.rs

1//! Heartbeat window tracking and lost-worker failure surfacing.
2
3use chrono::{DateTime, Utc};
4use std::collections::{HashMap, HashSet};
5use std::sync::{Arc, Mutex, MutexGuard};
6use std::time::{Duration, Instant};
7use tokio::sync::{Notify, watch};
8use tracing::{error, info, warn};
9
10use aion_core::{ActivityId, Payload, WorkflowId};
11use aion_proto::{ProtoHeartbeat, WireError};
12
13use crate::error::ServerError;
14use crate::shutdown::DrainState;
15use crate::worker::dispatch::{
16    ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink,
17};
18use crate::worker::envelope::CompletionToken;
19use crate::worker::registry::{ConnectedWorkerRegistry, WorkerId};
20
21mod handover;
22mod sweep;
23
24use sweep::is_expired;
25
26/// In-flight activity assigned to a connected worker.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct InFlightActivity {
29    /// Owning workflow id.
30    pub workflow_id: WorkflowId,
31    /// Correlating activity id.
32    pub activity_id: ActivityId,
33    /// One-based delivery attempt this dispatch carries — the third axis of the
34    /// `(workflow, activity, attempt)` identity the transcript, the intervention
35    /// index, and history all key on.
36    ///
37    /// It is NOT part of the tracker's key: a completion is addressed by the
38    /// worker, workflow, and activity together, and widening the key would
39    /// break that addressing. It is carried so a reader asking about a SPECIFIC
40    /// attempt — the live describe join asking whose progress note this is —
41    /// can tell a tracked entry for the attempt it asked about from a lingering
42    /// entry for a superseded one, instead of attributing one attempt's note to
43    /// another.
44    pub attempt: u32,
45    /// Generation authorized to receive a result or synthesized loss.
46    pub completion_token: CompletionToken,
47}
48
49/// Observable liveness state for a single in-flight activity.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct TaskLiveness {
52    /// Worker currently responsible for the task.
53    pub worker_id: WorkerId,
54    /// Owning workflow id.
55    pub workflow_id: WorkflowId,
56    /// Correlating activity id.
57    pub activity_id: ActivityId,
58    /// One-based delivery attempt this tracked dispatch carries (see
59    /// [`InFlightActivity::attempt`]).
60    pub attempt: u32,
61    /// Generation authorized to receive a result or synthesized loss.
62    pub completion_token: CompletionToken,
63    /// Operator-configured heartbeat window used for expiry checks.
64    pub heartbeat_window: Duration,
65    /// Monotonic timestamp of assignment or the most recent heartbeat.
66    pub last_heartbeat_at: Instant,
67    /// Optional worker progress from the most recent heartbeat.
68    pub last_progress: Option<Payload>,
69    /// Wall-clock instant this process received the heartbeat that carried
70    /// [`Self::last_progress`]. `None` while no progress has been reported.
71    ///
72    /// A wall clock rather than the tracked [`Instant`] because this one is
73    /// REPORTED to operators, and a monotonic instant means nothing outside the
74    /// process that minted it. Server-side observability stamping only — the
75    /// determinism boundary governs workflow-visible time, and nothing here is
76    /// workflow-visible.
77    pub last_progress_at: Option<DateTime<Utc>>,
78}
79
80/// Result of accepting a heartbeat for an in-flight task.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct HeartbeatUpdate {
83    /// Updated liveness after recording the heartbeat.
84    pub liveness: TaskLiveness,
85}
86
87/// Tasks removed from tracking because a worker was declared lost.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct LostWorkerReport {
90    /// Lost worker removed from the connected-worker registry.
91    pub worker_id: WorkerId,
92    /// In-flight activities swept off the tracker: surfaced to the engine as
93    /// retryable failures on the `fail_*` paths, or parked for restart
94    /// recovery (nothing recorded, nothing delivered) on the graceful-drain
95    /// `park_*` paths (#207).
96    pub tasks: Vec<InFlightActivity>,
97    /// Task queue the lost worker was serving, captured from the registry
98    /// BEFORE deregistration (afterwards the handle is gone and the queue is
99    /// unknowable). `None` when the worker was already absent from the registry.
100    ///
101    /// This is what lets the deregistration log name the queue an operator has
102    /// to act on, rather than an opaque worker id.
103    pub task_queue: Option<String>,
104}
105
106#[derive(Clone, Debug, Eq, Hash, PartialEq)]
107struct TaskKey(WorkerId, WorkflowId, ActivityId);
108
109#[derive(Debug, Default)]
110struct HeartbeatState {
111    tasks: HashMap<TaskKey, TaskLiveness>,
112    /// Last frame observed on each live worker connection.
113    ///
114    /// This is the PROCESS-IS-ALIVE fact, and only that. It is advanced by
115    /// anything the worker sends — including the worker-side liveness pump,
116    /// which beats from a background task regardless of what its serve loop is
117    /// doing. A fresh entry here means "that process is running and its
118    /// worker-to-server direction works". It does NOT mean the server can
119    /// reach it.
120    connections: HashMap<WorkerId, Instant>,
121    /// Last time the server PROVED it can reach this worker's dispatch path,
122    /// i.e. the last answered liveness ping.
123    ///
124    /// This is the SERVER-CAN-REACH-THE-WORKER fact, and it is the only one
125    /// that is a dispatch precondition. It is advanced ONLY by an answered
126    /// ping, never by an inbound frame, because only the ping rides the same
127    /// server-to-worker leg a dispatch does.
128    ///
129    /// The two facts are separate because collapsing them hid a total outage:
130    /// on run `dfd2117c` the server could not push to a worker for fifteen
131    /// minutes while the worker's pump kept the single old lease perfectly
132    /// fresh, so the dead-man switch could not fire for the one failure it
133    /// exists to detect. `liminal_transport`'s own doc already forbids this —
134    /// the ping proves "the exact path a dispatch would take, not a parallel
135    /// one that could be healthy while the real one is not" — and the pump
136    /// feeding the same lease was exactly that parallel channel.
137    reachability: HashMap<WorkerId, Reachability>,
138}
139
140/// How many CONSECUTIVE answered pings re-admit a worker to dispatch.
141///
142/// A connection is a channel to prove reachability ON, never proof of it: the
143/// registration handshake's ack is SENT by the server, and a sent ack is not a
144/// received one — exactly the inference this whole lane exists to stop making.
145/// So eligibility is earned by measurement, on every connection including the
146/// first, and a redial re-seeds the measurement OPPORTUNITY rather than the
147/// verdict.
148///
149/// Why two and not one: one success re-admits a link that answered once by luck
150/// — a race, a buffer that happened to drain — so a link answering one probe in
151/// three would flap in and out of eligibility indefinitely, which is the defect
152/// this constant exists to remove rather than slow down. Two consecutive
153/// successes is the smallest number that distinguishes "answered" from
154/// "answering".
155///
156/// Why not three or more: the cost is paid on EVERY connect, in probe cadences.
157/// At the probe's cadence a fresh worker is undispatchable for `K` cadences
158/// while its first dispatches park, and that latency is charged to every honest
159/// worker to catch a dishonest one. Two buys the discrimination; three buys
160/// only delay.
161pub(crate) const DISPATCH_PROBATION_PINGS: u32 = 2;
162
163/// A worker's dispatch-path standing: how many consecutive pings it has
164/// answered, and when the most recent one landed.
165///
166/// `proved_at` is `None` until the probation is served, so a worker on
167/// probation is not merely stale — it has no proof at all, which is the honest
168/// description of a connection nothing has been measured on yet.
169#[derive(Clone, Copy, Debug, Default)]
170struct Reachability {
171    consecutive_answers: u32,
172    proved_at: Option<Instant>,
173    /// Whether this worker has EVER held dispatch eligibility on this
174    /// connection. Not a duplicate of the two fields above: they describe the
175    /// current standing, this describes the connection's history, and only the
176    /// history separates a worker still serving its opening probation from one
177    /// that earned eligibility and then lost it.
178    ///
179    /// Deliberately NOT cleared by [`HeartbeatTracker::record_dispatch_unreachable`]
180    /// — a failed ping ends the current proof, it does not un-happen the proof
181    /// that came before it. Cleared only by
182    /// [`HeartbeatTracker::register_connection`], because a new connection is a
183    /// new measurement and nothing earned on the old one carries across.
184    ever_proved: bool,
185}
186
187impl Reachability {
188    /// Whether the probation is served and the proof is still inside `window`.
189    fn is_proved(self, now: Instant, window: Duration) -> bool {
190        self.consecutive_answers >= DISPATCH_PROBATION_PINGS
191            && self.proved_at.is_some_and(|proved_at| {
192                now.checked_duration_since(proved_at)
193                    .is_none_or(|elapsed| elapsed <= window)
194            })
195    }
196
197    /// Why this standing does not currently permit dispatch.
198    ///
199    /// Only meaningful when [`Self::is_proved`] is false; the caller pairs them
200    /// so the classification and the membership test can never disagree about
201    /// which workers are excluded.
202    fn exclusion(self) -> DispatchExclusion {
203        if self.ever_proved {
204            DispatchExclusion::ReachabilityLost
205        } else {
206            DispatchExclusion::OpeningProbation {
207                answers: self.consecutive_answers,
208            }
209        }
210    }
211}
212
213/// Why a worker is currently excluded from dispatch selection.
214///
215/// These are DIFFERENT FACTS and an operator must be able to tell them apart —
216/// the same standard this module already holds the two ping failures to. One is
217/// the ordinary cost of connecting; the other is an incident. Reported as one
218/// value alongside the exclusion itself so nothing has to re-derive the reason
219/// from a second reading of the same state.
220#[derive(Clone, Copy, Debug, PartialEq, Eq)]
221pub enum DispatchExclusion {
222    /// The worker registered and has not yet answered
223    /// [`DISPATCH_PROBATION_PINGS`] consecutive pings, so eligibility has never
224    /// been earned on this connection. Expected on EVERY connect, including a
225    /// perfectly healthy one — this is the probation being served, not a fault.
226    OpeningProbation {
227        /// Consecutive answers banked so far, out of [`DISPATCH_PROBATION_PINGS`].
228        answers: u32,
229    },
230    /// The worker held dispatch eligibility on this connection and no longer
231    /// does: either a ping failed and restarted its probation, or the last
232    /// proof aged out of the heartbeat window. This one is an incident.
233    ReachabilityLost,
234}
235
236/// One worker excluded from dispatch, with the reason it is excluded.
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub struct ExcludedWorker {
239    /// The worker selection must skip.
240    pub worker_id: WorkerId,
241    /// Why it is being skipped.
242    pub exclusion: DispatchExclusion,
243}
244
245/// Per-task liveness tracker for remote-worker streams.
246///
247/// It is also the server's ONLY store of worker progress notes, and that store
248/// is VOLATILE: notes live here and nowhere else, so a restart loses every note
249/// ever reported. [`Self::notes_held_since`] is the evidence a reader needs to
250/// tell "this worker has said nothing" from "this process was not running when
251/// the attempt began" — see `crate::worker::attempt_progress`.
252#[derive(Clone, Debug)]
253pub struct HeartbeatTracker {
254    heartbeat_window: Duration,
255    inner: Arc<Mutex<HeartbeatState>>,
256    empty: Arc<Notify>,
257    notes_held_since: DateTime<Utc>,
258}
259
260impl HeartbeatTracker {
261    /// Build a tracker using the operator-supplied heartbeat window.
262    #[must_use]
263    pub fn new(heartbeat_window: Duration) -> Self {
264        Self {
265            heartbeat_window,
266            inner: Arc::new(Mutex::new(HeartbeatState::default())),
267            empty: Arc::new(Notify::new()),
268            // The instant this volatile note store began holding notes. Read
269            // here rather than injected because it is exactly the tracker's own
270            // construction instant — there is no other value it could be, and a
271            // caller passing a different one would be reporting a fiction.
272            notes_held_since: Utc::now(),
273        }
274    }
275
276    /// When this tracker began holding progress notes.
277    ///
278    /// An attempt dispatched before this instant left its notes in a process
279    /// that no longer exists, so their absence here says nothing about what the
280    /// worker reported.
281    #[must_use]
282    pub const fn notes_held_since(&self) -> DateTime<Utc> {
283        self.notes_held_since
284    }
285
286    /// Start the connection-level lease for a newly registered worker, and open
287    /// its dispatch probation.
288    ///
289    /// The connection lease starts fresh — the worker's process is plainly
290    /// alive, it just registered. Dispatch reachability does NOT: a new
291    /// connection is a channel to prove reachability on, not proof of it.
292    ///
293    /// This deliberately reverses an earlier reading of mine, that the
294    /// registration handshake is "itself a completed server-to-worker round
295    /// trip". The ack is SENT by the server; nothing reports that it was
296    /// RECEIVED. Treating a send as a delivery is the same inference this lane
297    /// exists to stop making, and left unfixed it meant a worker the server
298    /// could never reach would re-seed itself on every redial and cycle in and
299    /// out of eligibility forever instead of settling out.
300    ///
301    /// See [`DISPATCH_PROBATION_PINGS`].
302    ///
303    /// # Errors
304    ///
305    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
306    pub fn register_connection(
307        &self,
308        worker_id: WorkerId,
309        now: Instant,
310    ) -> Result<(), ServerError> {
311        let mut state = self.state()?;
312        state.connections.insert(worker_id, now);
313        // A fresh, UNSERVED probation: zero answers, no proof. Inserted rather
314        // than left absent so the worker is carried by `unreachable_workers`
315        // and is therefore explicitly excluded, not merely unknown.
316        state
317            .reachability
318            .insert(worker_id, Reachability::default());
319        Ok(())
320    }
321
322    /// Advance a worker's connection lease after receiving any frame.
323    ///
324    /// Records ONLY that the worker's process is alive. It deliberately does
325    /// NOT advance dispatch reachability: an inbound frame — a heartbeat, a
326    /// pump beat, a completion — proves the worker-to-server direction and
327    /// says nothing about whether the server can push to it. Use
328    /// [`Self::record_dispatch_reachability`] for the fact that gates dispatch.
329    ///
330    /// Returns `false` if the worker has already been removed from lease tracking;
331    /// a frame racing deregistration must not resurrect it.
332    ///
333    /// # Errors
334    ///
335    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
336    pub fn record_connection_activity(
337        &self,
338        worker_id: WorkerId,
339        now: Instant,
340    ) -> Result<bool, ServerError> {
341        let mut state = self.state()?;
342        let Some(last_activity) = state.connections.get_mut(&worker_id) else {
343            return Ok(false);
344        };
345        *last_activity = now;
346        Ok(true)
347    }
348
349    /// Record proof that the server can reach this worker's dispatch path — an
350    /// ANSWERED liveness ping, and nothing else.
351    ///
352    /// Advances both facts, because an answered ping proves both: the worker
353    /// received a server push (reachability) and replied to it (alive). It also
354    /// serves one ping of the dispatch probation; eligibility returns once
355    /// [`DISPATCH_PROBATION_PINGS`] consecutive answers have landed.
356    ///
357    /// Returns `false` if the worker has already been removed from lease
358    /// tracking; a pong racing a reap must not resurrect it.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
363    pub fn record_dispatch_reachability(
364        &self,
365        worker_id: WorkerId,
366        now: Instant,
367    ) -> Result<bool, ServerError> {
368        let mut state = self.state()?;
369        let Some(last_activity) = state.connections.get_mut(&worker_id) else {
370            return Ok(false);
371        };
372        *last_activity = now;
373        let standing = state.reachability.entry(worker_id).or_default();
374        standing.consecutive_answers = standing.consecutive_answers.saturating_add(1);
375        standing.proved_at = Some(now);
376        if standing.consecutive_answers >= DISPATCH_PROBATION_PINGS {
377            // The probation is served. Recording it here — at the one place a
378            // probation can complete — is what lets a later exclusion say
379            // whether eligibility was ever held, without a second copy of the
380            // threshold anywhere else.
381            standing.ever_proved = true;
382        }
383        Ok(true)
384    }
385
386    /// Record that a liveness ping went UNANSWERED: the probation restarts.
387    ///
388    /// This is what makes the probation consecutive rather than cumulative. A
389    /// link answering one probe in three would otherwise accumulate its way to
390    /// eligibility and keep it, which is the flapping this design removes.
391    ///
392    /// Returns `false` if the worker has already been removed from lease
393    /// tracking.
394    ///
395    /// # Errors
396    ///
397    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
398    pub fn record_dispatch_unreachable(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
399        let mut state = self.state()?;
400        if !state.connections.contains_key(&worker_id) {
401            return Ok(false);
402        }
403        let standing = state.reachability.entry(worker_id).or_default();
404        standing.consecutive_answers = 0;
405        standing.proved_at = None;
406        // `ever_proved` deliberately survives: this connection DID earn
407        // eligibility once, and that is what makes the loss an incident rather
408        // than the ordinary cost of connecting.
409        Ok(true)
410    }
411
412    /// Whether the server has proved, within the heartbeat window, that it can
413    /// reach this worker's dispatch path — probation served AND the proof still
414    /// fresh.
415    ///
416    /// An untracked worker is not reachable: absence of proof is not proof.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
421    pub fn is_dispatch_reachable(
422        &self,
423        worker_id: WorkerId,
424        now: Instant,
425    ) -> Result<bool, ServerError> {
426        let state = self.state()?;
427        Ok(state
428            .reachability
429            .get(&worker_id)
430            .is_some_and(|standing| standing.is_proved(now, self.heartbeat_window)))
431    }
432
433    /// Every tracked worker the server has NOT been able to reach within the
434    /// heartbeat window, regardless of how alive its process looks, each paired
435    /// with WHY it is excluded.
436    ///
437    /// The reason travels with the membership rather than being recomputed by
438    /// the caller, so the set that gates dispatch and the reason an operator is
439    /// told can never describe different states.
440    ///
441    /// # Errors
442    ///
443    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
444    pub fn unreachable_workers(&self, now: Instant) -> Result<Vec<ExcludedWorker>, ServerError> {
445        let state = self.state()?;
446        let mut workers = state
447            .reachability
448            .iter()
449            .filter(|(_, standing)| !standing.is_proved(now, self.heartbeat_window))
450            .map(|(worker_id, standing)| ExcludedWorker {
451                worker_id: *worker_id,
452                exclusion: standing.exclusion(),
453            })
454            .collect::<Vec<_>>();
455        workers.sort_unstable_by_key(|excluded| excluded.worker_id);
456        Ok(workers)
457    }
458
459    /// End connection-lease tracking when a transport closes normally.
460    ///
461    /// # Errors
462    ///
463    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
464    pub fn unregister_connection(&self, worker_id: WorkerId) -> Result<(), ServerError> {
465        let mut state = self.state()?;
466        state.connections.remove(&worker_id);
467        state.reachability.remove(&worker_id);
468        Ok(())
469    }
470
471    /// Track a newly accepted in-flight activity for heartbeat expiry.
472    ///
473    /// # The reservation, and why it is taken by value
474    ///
475    /// `reservation` is the capacity slot the caller claimed at selection.
476    /// Passing it here is how the slot is HANDED OVER rather than re-counted:
477    /// this call commits it and skips its own increment, so the worker's
478    /// `in_flight` goes 1 → 1 instead of 1 → 2 → 1.
479    ///
480    /// That intermediate value was not harmless. The tracker incremented under
481    /// one registry lock and the reservation released under another, and a
482    /// concurrent leg landing between them was refused a slot the worker
483    /// demonstrably had — measured at `in_flight = 5` on a worker advertising 4,
484    /// on a fan sized exactly to its pool.
485    ///
486    /// Taken BY VALUE so the transfer cannot be half-done: this call either
487    /// commits the reservation or drops it, and a dropped one still releases.
488    /// `None` is for callers that never reserved — the in-process façades, and
489    /// any path tracking work it did not select.
490    ///
491    /// # Errors
492    ///
493    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
494    pub fn track_task(
495        &self,
496        worker_id: WorkerId,
497        task: InFlightActivity,
498        now: Instant,
499        registry: &ConnectedWorkerRegistry,
500        reservation: Option<crate::worker::registry::DispatchReservation>,
501    ) -> Result<(), ServerError> {
502        let key = TaskKey::new(
503            worker_id,
504            task.workflow_id.clone(),
505            task.activity_id.clone(),
506        );
507        let liveness = TaskLiveness {
508            worker_id,
509            workflow_id: task.workflow_id,
510            activity_id: task.activity_id,
511            attempt: task.attempt,
512            completion_token: task.completion_token,
513            heartbeat_window: self.heartbeat_window,
514            last_heartbeat_at: now,
515            last_progress: None,
516            last_progress_at: None,
517        };
518        // A DISPATCH IS NOT EVIDENCE THE WORKER IS ALIVE, so this no longer
519        // refreshes `state.connections`.
520        //
521        // It used to. That made the server's own act of pushing work advance the
522        // clock it later reads to decide whether the worker is still there: a
523        // worker that had stopped answering could be kept out of the expiry
524        // sweep indefinitely by nothing more than the server continuing to
525        // dispatch to it. Liveness is proven by what the WORKER does — a
526        // heartbeat (`record_heartbeat`) or a liveness answer
527        // (`record_liveness`) — and by nothing the server does to it.
528        let first_tracking = {
529            let mut state = self.state()?;
530            state.tasks.insert(key, liveness).is_none()
531        };
532        // GATED ON THE INSERT, because the two writes have different
533        // idempotencies. `tasks` is keyed by `(worker, workflow, activity)` and
534        // an insert over a live key OVERWRITES — one entry before, one entry
535        // after — while the registry count increments unconditionally. Tracking
536        // the same key twice without an intervening `complete_task` therefore
537        // used to leak a slot permanently: the count went up twice and could
538        // only ever come down once, so the worker's usable capacity shrank until
539        // it deregistered.
540        //
541        // `registry` is an argument precisely so the liveness record and the
542        // number selection reads are written by ONE call and cannot be updated
543        // apart. Gating keeps that property and makes them agree on how many
544        // times one dispatch counts.
545        handover::settle_reserved_slot(first_tracking, worker_id, registry, reservation)
546    }
547
548    /// Stop tracking a completed activity and wake drain waiters if this was the last task.
549    ///
550    /// Returns whether the task was still tracked when this ran: `true` means
551    /// THIS call retired the in-flight entry, `false` means another path (the
552    /// expiry sweep, a disconnect teardown, shutdown, or a completed dispatch)
553    /// already did. The liminal reply router uses that bool as its structural
554    /// gate for synthesizing a lost-worker failure — the exact mirror of the
555    /// gRPC sweep failing only still-tracked tasks.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
560    pub fn complete_task(
561        &self,
562        worker_id: WorkerId,
563        workflow_id: &WorkflowId,
564        activity_id: &ActivityId,
565        registry: &ConnectedWorkerRegistry,
566    ) -> Result<bool, ServerError> {
567        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
568        let (was_tracked, became_empty) = {
569            let mut state = self.state()?;
570            let was_tracked = state.tasks.remove(&key).is_some();
571            (was_tracked, state.tasks.is_empty())
572        };
573        if was_tracked {
574            // Only THIS call's removal frees a slot. A second caller finding
575            // the entry already gone must not decrement again, or a worker
576            // would be credited with capacity it does not have. The liminal
577            // delivery guard (`AbandonedDispatchGuard`) leans on exactly this
578            // when it fires beside the caller's own untrack. The registry
579            // side also wakes every dispatch parked on a full pool — the freed
580            // slot is the only event such a dispatch is waiting for.
581            registry.record_dispatch_finished(worker_id)?;
582        }
583        if became_empty {
584            self.empty.notify_waiters();
585        }
586        Ok(was_tracked)
587    }
588
589    /// Whether the given in-flight task is still tracked (not yet completed,
590    /// swept, or drained). The liminal reply router polls this to bound its
591    /// wait: once the entry is gone the dispatch was resolved by another path,
592    /// so the router exits instead of parking on the connection forever.
593    ///
594    /// # Errors
595    ///
596    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
597    pub fn is_tracked(
598        &self,
599        worker_id: WorkerId,
600        workflow_id: &WorkflowId,
601        activity_id: &ActivityId,
602    ) -> Result<bool, ServerError> {
603        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
604        Ok(self.state()?.tasks.contains_key(&key))
605    }
606
607    /// Refresh the liveness stamp of an in-flight task from a transport-level
608    /// liveness beat that carries no progress payload (the liminal worker's
609    /// automatic pump). Returns `true` when the task was tracked and refreshed,
610    /// `false` when it is not in flight — a benign outcome for a beat racing a
611    /// completion or covering an outbox dispatch the tracker never held.
612    ///
613    /// # Errors
614    ///
615    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
616    pub fn record_liveness(
617        &self,
618        worker_id: WorkerId,
619        workflow_id: &WorkflowId,
620        activity_id: &ActivityId,
621        now: Instant,
622    ) -> Result<bool, ServerError> {
623        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
624        let mut state = self.state()?;
625        if !state.tasks.contains_key(&key) {
626            return Ok(false);
627        }
628        if let Some(last_activity) = state.connections.get_mut(&worker_id) {
629            *last_activity = now;
630        }
631        let Some(liveness) = state.tasks.get_mut(&key) else {
632            return Ok(false);
633        };
634        liveness.last_heartbeat_at = now;
635        Ok(true)
636    }
637
638    /// The operator-configured heartbeat window this tracker expires against.
639    /// The bridge stamps it onto each liminal dispatch so the worker's
640    /// automatic liveness pump beats at the matching quarter-window cadence.
641    #[must_use]
642    pub const fn heartbeat_window(&self) -> Duration {
643        self.heartbeat_window
644    }
645
646    /// Number of currently tracked in-flight activities.
647    ///
648    /// # Errors
649    ///
650    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
651    pub fn in_flight_count(&self) -> Result<usize, ServerError> {
652        Ok(self.state()?.tasks.len())
653    }
654
655    /// Number of in-flight activities tracked for ONE worker.
656    ///
657    /// Beside [`Self::in_flight_count`] rather than replacing it: that answer is
658    /// deliberately global (the drain gate asks "is anything still running"),
659    /// and widening it to mean per-worker would make every existing caller read
660    /// a different question than the one it asked.
661    ///
662    /// This is the tracker's own count, derived from the task map it owns. The
663    /// registry keeps a projection of the same fact for selection to read
664    /// without taking this lock, and the two are written by one call
665    /// ([`Self::track_task`] / [`Self::complete_task`]). This accessor is what
666    /// lets a test hold them against each other instead of trusting that.
667    ///
668    /// # Errors
669    ///
670    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
671    pub fn in_flight_for_worker(&self, worker_id: WorkerId) -> Result<usize, ServerError> {
672        Ok(self
673            .state()?
674            .tasks
675            .keys()
676            .filter(|key| key.worker_id() == worker_id)
677            .count())
678    }
679
680    /// Record a worker heartbeat without completing the activity.
681    ///
682    /// Every heartbeat refreshes the task's liveness stamp. The progress
683    /// payload is only overwritten when the heartbeat CARRIES one: the worker
684    /// runtime's automatic liveness beats are payload-free and interleave
685    /// with explicit handler progress heartbeats, and a liveness beat must
686    /// never erase the handler's most recent progress report.
687    ///
688    /// # Errors
689    ///
690    /// Returns a stable wire error for malformed heartbeats or unknown in-flight tasks.
691    pub fn record_heartbeat(
692        &self,
693        worker_id: WorkerId,
694        heartbeat: ProtoHeartbeat,
695        now: Instant,
696    ) -> Result<HeartbeatUpdate, ServerError> {
697        let decoded = DecodedHeartbeat::try_from(heartbeat)?;
698        let key = TaskKey::new(worker_id, decoded.workflow_id, decoded.activity_id);
699        let mut state = self.state()?;
700        if !state.tasks.contains_key(&key) {
701            return Err(wire_error("heartbeat task is not in flight"));
702        }
703        if let Some(last_activity) = state.connections.get_mut(&worker_id) {
704            *last_activity = now;
705        }
706        let Some(liveness) = state.tasks.get_mut(&key) else {
707            return Err(wire_error("heartbeat task is not in flight"));
708        };
709        liveness.last_heartbeat_at = now;
710        if decoded.progress.is_some() {
711            liveness.last_progress = decoded.progress;
712            liveness.last_progress_at = Some(Utc::now());
713        }
714        Ok(HeartbeatUpdate {
715            liveness: liveness.clone(),
716        })
717    }
718
719    /// Return whether an in-flight task is still within its configured heartbeat window.
720    ///
721    /// # Errors
722    ///
723    /// Returns a stable wire error if the task is not tracked, or lock poison if state cannot be trusted.
724    pub fn is_live(
725        &self,
726        worker_id: WorkerId,
727        workflow_id: &WorkflowId,
728        activity_id: &ActivityId,
729        now: Instant,
730    ) -> Result<bool, ServerError> {
731        let key = TaskKey::new(worker_id, workflow_id.clone(), activity_id.clone());
732        let state = self.state()?;
733        let Some(liveness) = state.tasks.get(&key) else {
734            return Err(wire_error("heartbeat task is not in flight"));
735        };
736        Ok(!is_expired(liveness, now))
737    }
738
739    /// Mark all currently expired workers lost and fail their in-flight tasks through the engine sink.
740    ///
741    /// # Errors
742    ///
743    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
744    pub fn fail_expired_workers(
745        &self,
746        registry: &ConnectedWorkerRegistry,
747        sink: &impl ActivityCompletionSink,
748        now: Instant,
749    ) -> Result<Vec<LostWorkerReport>, ServerError> {
750        let mut reports = Vec::new();
751        for worker_id in self.expired_workers(registry, now)? {
752            let report = self.fail_lost_worker(worker_id, registry, sink)?;
753            reports.push(report);
754        }
755        Ok(reports)
756    }
757
758    /// Mark a disconnected worker lost and fail its in-flight tasks through the engine sink.
759    ///
760    /// # Errors
761    ///
762    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
763    pub fn fail_disconnected_worker(
764        &self,
765        worker_id: WorkerId,
766        registry: &ConnectedWorkerRegistry,
767        sink: &impl ActivityCompletionSink,
768    ) -> Result<LostWorkerReport, ServerError> {
769        self.fail_lost_worker(worker_id, registry, sink)
770    }
771
772    /// Mark every currently in-flight worker lost and fail all remaining tasks through the sink.
773    ///
774    /// # Errors
775    ///
776    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
777    pub fn fail_all_in_flight_workers(
778        &self,
779        registry: &ConnectedWorkerRegistry,
780        sink: &impl ActivityCompletionSink,
781    ) -> Result<Vec<LostWorkerReport>, ServerError> {
782        let worker_ids = {
783            let state = self.state()?;
784            let mut worker_ids = state
785                .tasks
786                .values()
787                .map(|liveness| liveness.worker_id)
788                .collect::<HashSet<_>>()
789                .into_iter()
790                .collect::<Vec<_>>();
791            worker_ids.sort_unstable();
792            worker_ids
793        };
794        let mut reports = Vec::new();
795        for worker_id in worker_ids {
796            let report = self.fail_lost_worker(worker_id, registry, sink)?;
797            if !report.tasks.is_empty() {
798                reports.push(report);
799            }
800        }
801        self.empty.notify_waiters();
802        Ok(reports)
803    }
804
805    /// Park a drain-disconnected worker's in-flight tasks for restart recovery
806    /// (#207): deregister the worker, remove its tracked tasks, and resolve
807    /// each pending waiter through [`ActivityCompletionSink::park_activity`].
808    ///
809    /// The graceful-drain counterpart of [`Self::fail_disconnected_worker`]:
810    /// same deregister-before-collect ordering (same closed dispatch/disconnect
811    /// race), but NO completion is synthesized — the durable log keeps its
812    /// dangling scheduled/started trail, byte-equivalent to a kill -9, and
813    /// restart recovery re-dispatches it. Deregistered with the honest
814    /// [`WorkerDeathReason::Disconnect`](aion_core::WorkerDeathReason::Disconnect):
815    /// the transport genuinely dropped (the worker obeyed the drain request).
816    ///
817    /// # Errors
818    ///
819    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
820    pub fn park_disconnected_worker(
821        &self,
822        worker_id: WorkerId,
823        registry: &ConnectedWorkerRegistry,
824        sink: &impl ActivityCompletionSink,
825    ) -> Result<LostWorkerReport, ServerError> {
826        self.park_lost_worker(
827            worker_id,
828            registry,
829            sink,
830            aion_core::WorkerDeathReason::Disconnect,
831        )
832    }
833
834    /// Park EVERY currently in-flight worker's tasks for restart recovery
835    /// (#207) — the drain-timeout backstop's bulk counterpart of
836    /// [`Self::fail_all_in_flight_workers`].
837    ///
838    /// Deregistered with the honest
839    /// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout):
840    /// the drain window genuinely expired on these workers. Wakes drain waiters
841    /// after the sweep so `wait_for_empty` observes the emptied tracker.
842    ///
843    /// # Errors
844    ///
845    /// Returns registry, tracker, or sink errors without retrying or rescheduling activities.
846    pub fn park_all_in_flight_workers(
847        &self,
848        registry: &ConnectedWorkerRegistry,
849        sink: &impl ActivityCompletionSink,
850    ) -> Result<Vec<LostWorkerReport>, ServerError> {
851        let worker_ids = {
852            let state = self.state()?;
853            let mut worker_ids = state
854                .tasks
855                .values()
856                .map(|liveness| liveness.worker_id)
857                .collect::<HashSet<_>>()
858                .into_iter()
859                .collect::<Vec<_>>();
860            worker_ids.sort_unstable();
861            worker_ids
862        };
863        let mut reports = Vec::new();
864        for worker_id in worker_ids {
865            let report = self.park_lost_worker(
866                worker_id,
867                registry,
868                sink,
869                aion_core::WorkerDeathReason::Timeout,
870            )?;
871            if !report.tasks.is_empty() {
872                reports.push(report);
873            }
874        }
875        self.empty.notify_waiters();
876        Ok(reports)
877    }
878
879    /// Shared park core (#207), structured exactly like [`Self::fail_lost_worker`]
880    /// — deregister BEFORE collecting tasks (see that method's race note) — but
881    /// resolving each waiter with the ephemeral parked sentinel instead of
882    /// synthesizing a lost-worker `ActivityFailed`. Idempotent for the same
883    /// reasons: `deregister_with_reason` no-ops on an already-removed worker and
884    /// each task is removed as it parks, so a second sweep (park or fail) sees
885    /// an empty report and resolves nothing.
886    fn park_lost_worker(
887        &self,
888        worker_id: WorkerId,
889        registry: &ConnectedWorkerRegistry,
890        sink: &impl ActivityCompletionSink,
891        reason: aion_core::WorkerDeathReason,
892    ) -> Result<LostWorkerReport, ServerError> {
893        let task_queue = task_queue_of(registry, worker_id);
894        registry.deregister_with_reason(worker_id, reason)?;
895        self.state()?.connections.remove(&worker_id);
896        let tasks = self.remove_worker_tasks(worker_id)?;
897        for task in &tasks {
898            sink.park_activity(&task.workflow_id, &task.activity_id)?;
899            info!(
900                worker_id = ?worker_id,
901                workflow_id = %task.workflow_id,
902                activity_id = %task.activity_id,
903                "activity parked for restart recovery"
904            );
905        }
906        Ok(LostWorkerReport {
907            worker_id,
908            tasks,
909            task_queue,
910        })
911    }
912
913    fn fail_lost_worker(
914        &self,
915        worker_id: WorkerId,
916        registry: &ConnectedWorkerRegistry,
917        sink: &impl ActivityCompletionSink,
918    ) -> Result<LostWorkerReport, ServerError> {
919        // Deregister BEFORE collecting tasks: the dispatch path tracks its
920        // task, sends, and then checks `registry.is_registered`. With this
921        // ordering, a dispatch that still sees the worker registered is
922        // guaranteed its tracked task is visible to any later sweep, so the
923        // unbounded completion wait always gets a lost-worker failure. (The
924        // reverse order leaves a window where a task tracked between the
925        // collection and the deregistration is never failed by anyone.)
926        // This is the liveness-timeout sweep: the proven reason is Timeout, the
927        // one finer-grained WS3 distinction this call site can honestly assert.
928        // The queue is read BEFORE the deregistration below, because afterwards
929        // the handle is gone and the log could no longer name it.
930        let task_queue = task_queue_of(registry, worker_id);
931        registry.deregister_with_reason(worker_id, aion_core::WorkerDeathReason::Timeout)?;
932        self.state()?.connections.remove(&worker_id);
933        let tasks = self.remove_worker_tasks(worker_id)?;
934        for task in &tasks {
935            sink.complete_activity(ActivityCompletion {
936                workflow_id: task.workflow_id.clone(),
937                activity_id: task.activity_id.clone(),
938                run_id: None,
939                completion_token: task.completion_token.clone(),
940                // A TRANSPORT-domain loss, not an activity failure: the
941                // activity never executed to a result. The sink classifies it
942                // (and applies the transport's own re-dispatch budget); this
943                // sweep only reports what it observed.
944                outcome: ActivityCompletionOutcome::WorkerLost { worker_id },
945            })?;
946        }
947        Ok(LostWorkerReport {
948            worker_id,
949            tasks,
950            task_queue,
951        })
952    }
953
954    fn remove_worker_tasks(
955        &self,
956        worker_id: WorkerId,
957    ) -> Result<Vec<InFlightActivity>, ServerError> {
958        let mut state = self.state()?;
959        let keys = state
960            .tasks
961            .keys()
962            .filter(|key| key.worker_id() == worker_id)
963            .cloned()
964            .collect::<Vec<_>>();
965        let mut tasks = Vec::with_capacity(keys.len());
966        for key in keys {
967            if let Some(liveness) = state.tasks.remove(&key) {
968                tasks.push(InFlightActivity {
969                    workflow_id: liveness.workflow_id,
970                    activity_id: liveness.activity_id,
971                    attempt: liveness.attempt,
972                    completion_token: liveness.completion_token,
973                });
974            }
975        }
976        Ok(tasks)
977    }
978
979    /// Every tracked in-flight entry for `(workflow, activity, attempt)`.
980    ///
981    /// Read from the SAME map the heartbeat path writes, so what a reader is
982    /// told and what the dispatch path knows cannot drift. Usually zero or one
983    /// entry; a within-attempt failover can briefly have a dying owner and its
984    /// adopter both tracked, which is why this returns them all and leaves the
985    /// choice to the caller (`crate::worker::attempt_progress`) rather than
986    /// silently picking one here.
987    ///
988    /// # Errors
989    ///
990    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
991    pub(in crate::worker) fn attempt_entries(
992        &self,
993        workflow_id: &WorkflowId,
994        activity_id: &ActivityId,
995        attempt: u32,
996    ) -> Result<Vec<TaskLiveness>, ServerError> {
997        Ok(self
998            .state()?
999            .tasks
1000            .values()
1001            .filter(|liveness| {
1002                &liveness.workflow_id == workflow_id
1003                    && &liveness.activity_id == activity_id
1004                    && liveness.attempt == attempt
1005            })
1006            .cloned()
1007            .collect())
1008    }
1009
1010    /// Every activity of `workflow_id` a connected worker is holding RIGHT NOW
1011    /// (#233).
1012    ///
1013    /// A tracked entry means a live worker owns that activity: entries are
1014    /// added at dispatch ([`Self::track_task`]) and removed on completion,
1015    /// disconnect, or heartbeat expiry. That is what makes this — rather than
1016    /// the durable outbox row, which records no worker at all — the authority
1017    /// on where a cancellation has to be sent. The worker id here is present
1018    /// membership, not a recorded historical fact, so it cannot go stale the
1019    /// way a worker id written down at claim time would.
1020    ///
1021    /// A within-attempt failover can briefly show a dying owner AND its adopter
1022    /// for one activity. Both are returned: a cancel is addressed to a
1023    /// `(workflow, activity)` key that either worker may or may not still hold,
1024    /// and a worker that does not hold it ignores the message, so asking both
1025    /// is correct and asking one would be a guess.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns [`ServerError::LockPoisoned`] if tracker state cannot be trusted.
1030    pub fn in_flight_for_workflow(
1031        &self,
1032        workflow_id: &WorkflowId,
1033    ) -> Result<Vec<TaskLiveness>, ServerError> {
1034        Ok(self
1035            .state()?
1036            .tasks
1037            .values()
1038            .filter(|liveness| &liveness.workflow_id == workflow_id)
1039            .cloned()
1040            .collect())
1041    }
1042
1043    fn state(&self) -> Result<MutexGuard<'_, HeartbeatState>, ServerError> {
1044        self.inner
1045            .lock()
1046            .map_err(|_| ServerError::lock_poisoned("worker heartbeat tracker"))
1047    }
1048
1049    /// Poisons the tracker's state lock, for tests that pin the fail-open
1050    /// contract of paths that must never let a poisoned tracker withhold a
1051    /// completion. Unwinds across a held guard inside `catch_unwind`, which is
1052    /// exactly what real poison is.
1053    #[cfg(test)]
1054    pub(crate) fn poison_for_tests(&self) {
1055        let inner = Arc::clone(&self.inner);
1056        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
1057            let _guard = inner.lock();
1058            // `resume_unwind` raises the unwind directly (no panic hook, no
1059            // panic machinery in a non-test path): the guard drops while the
1060            // thread is unwinding, which is precisely how a mutex is poisoned.
1061            std::panic::resume_unwind(Box::new(
1062                "poisoning the heartbeat tracker for a fail-open test",
1063            ));
1064        }));
1065    }
1066}
1067
1068/// Sweep cadence derived from the operator's `worker.heartbeat_window`: a
1069/// quarter of the window, clamped to `[1s, window]` (the default 30s window
1070/// sweeps every 7.5s).
1071///
1072/// Deliberately derived rather than a separate config knob: the window is the
1073/// operational contract ("a silent worker is dead after this long"), and the
1074/// sweep cadence is an implementation detail of enforcing it — a quarter-window
1075/// cadence bounds detection latency at `window + window/4` while keeping the
1076/// sweep cheap. A window shorter than one second (test configurations) sweeps
1077/// once per window rather than sub-second-spinning, and a zero window is
1078/// floored at one millisecond because `tokio::time::interval` rejects a zero
1079/// period.
1080#[must_use]
1081pub fn sweep_interval(heartbeat_window: Duration) -> Duration {
1082    /// `tokio::time::interval` panics on a zero period, so even a
1083    /// (misconfigured) zero window gets a positive cadence.
1084    const MINIMUM_PERIOD: Duration = Duration::from_millis(1);
1085    /// Target lower bound: sweeping more often than once a second buys no
1086    /// meaningful detection latency against real heartbeat windows.
1087    const TARGET_FLOOR: Duration = Duration::from_secs(1);
1088    let ceiling = heartbeat_window.max(MINIMUM_PERIOD);
1089    // The floor never exceeds the ceiling, so `clamp` cannot panic.
1090    (heartbeat_window / 4).clamp(TARGET_FLOOR.min(ceiling), ceiling)
1091}
1092
1093/// Production driver of [`HeartbeatTracker::fail_expired_workers`] (#176).
1094///
1095/// The tracker records connection and per-task liveness, while the stream-teardown
1096/// sweep fails a worker whose stream ENDS. A worker whose stream stays open while
1097/// its process wedges is caught by the connection lease even when it is idle.
1098/// This interval task expires every silent connection or task, deregistering it
1099/// with the provable
1100/// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout) and
1101/// surfacing its tasks as TRANSPORT losses through the shared completion sink
1102/// — the `lost:` class the engine re-dispatches attempt-neutrally, never the
1103/// action's retry vocabulary. It shares the server's shutdown watch, so it drains with
1104/// the transports (mirroring
1105/// [`OutboxDispatcher::run`](crate::worker::OutboxDispatcher::run)).
1106///
1107/// Double-fail safety: this sweep and the stream-teardown path
1108/// ([`HeartbeatTracker::fail_disconnected_worker`]) can both observe the same
1109/// dead worker. Both funnel into the same idempotent core —
1110/// `deregister_with_reason` is a no-op for an already-removed worker (no
1111/// duplicate WS3 delta, no metrics double-count) and the tracker removes each
1112/// task as it fails it — so whichever path runs second sees an empty report and
1113/// never double-completes an activity.
1114pub struct HeartbeatSweeper<S> {
1115    tracker: HeartbeatTracker,
1116    registry: ConnectedWorkerRegistry,
1117    sink: S,
1118    drain: DrainState,
1119    heartbeat_window: Duration,
1120    interval: Duration,
1121    /// Live unserved-queue state, read only to state the CONSEQUENCE of a
1122    /// deregistration in the same log line as its cause: how many dispatches are
1123    /// already parked on the queue the reaped worker was serving. Default-empty
1124    /// in wirings that have no queue service, where the count reads zero.
1125    queue_state: crate::worker::QueueServiceState,
1126}
1127
1128impl<S> HeartbeatSweeper<S>
1129where
1130    S: ActivityCompletionSink + Send + Sync + 'static,
1131{
1132    /// Build a sweeper over the server's shared liveness tracker, worker
1133    /// registry, completion sink, and drain gate. The cadence is derived from
1134    /// `heartbeat_window` by [`sweep_interval`].
1135    #[must_use]
1136    pub fn new(
1137        tracker: HeartbeatTracker,
1138        registry: ConnectedWorkerRegistry,
1139        sink: S,
1140        drain: DrainState,
1141        heartbeat_window: Duration,
1142    ) -> Self {
1143        let interval = sweep_interval(heartbeat_window);
1144        Self {
1145            tracker,
1146            registry,
1147            sink,
1148            drain,
1149            heartbeat_window,
1150            interval,
1151            queue_state: crate::worker::QueueServiceState::default(),
1152        }
1153    }
1154
1155    /// Share the live unserved-queue state so a deregistration log can state how
1156    /// many dispatches are already parked on the queue the reaped worker served.
1157    ///
1158    /// Without it the count reads zero — honest for a wiring with no queue
1159    /// service, and never a reason to withhold the deregistration itself.
1160    #[must_use]
1161    pub fn with_queue_state(mut self, queue_state: crate::worker::QueueServiceState) -> Self {
1162        self.queue_state = queue_state;
1163        self
1164    }
1165
1166    /// Run the expiry sweep until `shutdown` flips to `true`.
1167    ///
1168    /// A tracker/registry error during a sweep is logged and retried next tick
1169    /// rather than tearing the task down — a transient failure must not
1170    /// silently stop dead-worker detection. Shutdown is observed both while
1171    /// waiting for the next tick and re-checked before each sweep, exactly like
1172    /// the outbox dispatcher's run loop.
1173    pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
1174        info!(
1175            sweep_interval_ms = self.interval.as_millis(),
1176            heartbeat_window_ms = self.heartbeat_window.as_millis(),
1177            "worker heartbeat sweeper started"
1178        );
1179        let mut ticks = tokio::time::interval(self.interval);
1180        ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1181        loop {
1182            tokio::select! {
1183                _ = ticks.tick() => {
1184                    if *shutdown.borrow() {
1185                        break;
1186                    }
1187                    self.sweep_once(Instant::now());
1188                }
1189                changed = shutdown.changed() => {
1190                    // A receive error means every sender dropped; treat that as
1191                    // a shutdown request rather than spinning.
1192                    if changed.is_err() || *shutdown.borrow() {
1193                        break;
1194                    }
1195                }
1196            }
1197        }
1198        info!("worker heartbeat sweeper stopped");
1199    }
1200
1201    /// Fail every currently-expired worker once, logging each lost-worker
1202    /// report at warn (mirroring the stream-teardown sweep's logging).
1203    fn sweep_once(&self, now: Instant) {
1204        let reports = match self
1205            .tracker
1206            .fail_expired_workers(&self.registry, &self.sink, now)
1207        {
1208            Ok(reports) => reports,
1209            Err(sweep_error) => {
1210                error!(
1211                    error = %sweep_error,
1212                    "heartbeat expiry sweep failed; retrying next tick"
1213                );
1214                return;
1215            }
1216        };
1217        for report in &reports {
1218            let task_queue = report.task_queue.as_deref().unwrap_or("<unregistered>");
1219            // The consequence, stated with the cause: a queue whose worker just
1220            // died and which already holds parked dispatches is an alertable
1221            // condition, and the operator should not have to join two log lines
1222            // to see it. A read failure reports `None` rather than suppressing
1223            // the deregistration line.
1224            let parked = report
1225                .task_queue
1226                .as_deref()
1227                .map(|queue| self.queue_state.parked_on_queue(queue))
1228                .transpose()
1229                .unwrap_or_else(|error| {
1230                    error!(%error, "could not read parked-dispatch count for a reaped worker");
1231                    None
1232                })
1233                .unwrap_or(0);
1234            if report.tasks.is_empty() {
1235                warn!(
1236                    worker_id = ?report.worker_id,
1237                    task_queue,
1238                    parked_dispatches = parked,
1239                    heartbeat_window_ms = self.heartbeat_window.as_millis(),
1240                    "idle worker connection lease expired; worker deregistered"
1241                );
1242            } else {
1243                warn!(
1244                    worker_id = ?report.worker_id,
1245                    task_queue,
1246                    parked_dispatches = parked,
1247                    failed_tasks = report.tasks.len(),
1248                    heartbeat_window_ms = self.heartbeat_window.as_millis(),
1249                    "worker heartbeat window expired with in-flight activities; \
1250                     deregistered and surfaced as transport losses, to be \
1251                     re-dispatched attempt-neutrally"
1252                );
1253            }
1254        }
1255        if !reports.is_empty() {
1256            // In-flight accounting may have just reached zero; wake any drain
1257            // waiter so shutdown does not sit out its full timeout (mirrors
1258            // the stream-teardown sweep).
1259            self.drain.notify_activity_drained();
1260        }
1261    }
1262}
1263
1264impl TaskKey {
1265    fn new(worker_id: WorkerId, workflow_id: WorkflowId, activity_id: ActivityId) -> Self {
1266        Self(worker_id, workflow_id, activity_id)
1267    }
1268
1269    const fn worker_id(&self) -> WorkerId {
1270        self.0
1271    }
1272}
1273
1274struct DecodedHeartbeat {
1275    workflow_id: WorkflowId,
1276    activity_id: ActivityId,
1277    progress: Option<Payload>,
1278}
1279
1280impl TryFrom<ProtoHeartbeat> for DecodedHeartbeat {
1281    type Error = ServerError;
1282
1283    fn try_from(value: ProtoHeartbeat) -> Result<Self, Self::Error> {
1284        let workflow_id = value
1285            .workflow_id
1286            .ok_or_else(|| wire_error("heartbeat workflow id is missing"))
1287            .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
1288        let activity_id = value
1289            .activity_id
1290            .ok_or_else(|| wire_error("heartbeat activity id is missing"))
1291            .map(ActivityId::from)?;
1292        let progress = value
1293            .progress
1294            .map(Payload::try_from)
1295            .transpose()
1296            .map_err(ServerError::from)?;
1297        Ok(Self {
1298            workflow_id,
1299            activity_id,
1300            progress,
1301        })
1302    }
1303}
1304
1305/// The task queue a still-registered worker serves, for the deregistration log.
1306///
1307/// A registry read failure (poisoned lock) yields `None` rather than aborting
1308/// the sweep: losing the queue NAME must never stop a dead worker being reaped.
1309fn task_queue_of(registry: &ConnectedWorkerRegistry, worker_id: WorkerId) -> Option<String> {
1310    registry
1311        .worker_by_id(worker_id)
1312        .ok()
1313        .flatten()
1314        .map(|handle| handle.task_queue().to_owned())
1315}
1316
1317fn wire_error(message: &'static str) -> ServerError {
1318    ServerError::Wire {
1319        wire: WireError::backend(message),
1320    }
1321}
1322
1323#[cfg(test)]
1324mod reachability_tests {
1325    use std::time::{Duration, Instant};
1326
1327    use super::{
1328        DISPATCH_PROBATION_PINGS, DispatchExclusion, ExcludedWorker, HeartbeatTracker, ServerError,
1329        WorkerId,
1330    };
1331
1332    const WINDOW: Duration = Duration::from_secs(30);
1333
1334    /// Every test returns `Result` and uses `?` rather than unwrapping: a lock
1335    /// fault inside the tracker is a real failure mode of the code under test,
1336    /// and it should surface as a failed test carrying the typed error, not as a
1337    /// panic message written by the test.
1338    type TestResult = Result<(), ServerError>;
1339
1340    fn tracker_with_worker(now: Instant) -> Result<(HeartbeatTracker, WorkerId), ServerError> {
1341        let tracker = HeartbeatTracker::new(WINDOW);
1342        let worker = WorkerId::from_value(1);
1343        tracker.register_connection(worker, now)?;
1344        Ok((tracker, worker))
1345    }
1346
1347    /// Answer the full probation, so the worker is genuinely eligible. Tests
1348    /// about staleness, pump beats, or withdrawal must start from a worker that
1349    /// HAS eligibility — otherwise they pass on a worker that never had any and
1350    /// prove nothing about the behaviour they name.
1351    fn serve_probation(
1352        tracker: &HeartbeatTracker,
1353        worker: WorkerId,
1354        at: Instant,
1355    ) -> Result<(), ServerError> {
1356        for _ in 0..DISPATCH_PROBATION_PINGS {
1357            assert!(
1358                tracker.record_dispatch_reachability(worker, at)?,
1359                "the worker must still be tracked while it serves its probation"
1360            );
1361        }
1362        Ok(())
1363    }
1364
1365    /// THE REGRESSION. This is the defect that made run `dfd2117c` invisible:
1366    /// the worker's liveness pump beat from a background task, refreshed the one
1367    /// shared lease, and the dead-man switch could not fire while the server had
1368    /// been unable to push to that worker for fifteen minutes.
1369    ///
1370    /// An inbound frame must prove the worker is ALIVE and must NOT prove the
1371    /// server can REACH it.
1372    #[test]
1373    fn an_inbound_frame_cannot_prove_dispatch_reachability() -> TestResult {
1374        let start = Instant::now();
1375        let (tracker, worker) = tracker_with_worker(start)?;
1376        // The worker STARTS eligible, earned honestly. Without this the test
1377        // would pass on a worker that never had eligibility to lose, which says
1378        // nothing about whether a pump beat can preserve it.
1379        serve_probation(&tracker, worker, start)?;
1380        assert!(
1381            tracker.is_dispatch_reachable(worker, start)?,
1382            "precondition: the worker is eligible before the connection goes one-way"
1383        );
1384
1385        // Well past the window, with the pump beating throughout — exactly what
1386        // a busy worker on a poisoned connection looks like.
1387        let much_later = start + WINDOW * 4;
1388        assert!(
1389            tracker.record_connection_activity(worker, much_later)?,
1390            "the worker is still tracked"
1391        );
1392
1393        assert!(
1394            !tracker.is_dispatch_reachable(worker, much_later)?,
1395            "a pump beat must NOT make a worker the server cannot push to look reachable"
1396        );
1397        assert_eq!(
1398            tracker.unreachable_workers(much_later)?,
1399            vec![ExcludedWorker {
1400                worker_id: worker,
1401                // It HELD eligibility (served above) and lost it to a stale
1402                // proof. Classifying this as an opening probation would tell an
1403                // operator a poisoned connection is an ordinary worker start.
1404                exclusion: DispatchExclusion::ReachabilityLost,
1405            }],
1406            "the worker must be named unreachable however alive its process looks"
1407        );
1408        Ok(())
1409    }
1410
1411    /// The control for the test above: without it, a tracker that reported
1412    /// EVERYTHING unreachable would satisfy that assertion and prove nothing.
1413    #[test]
1414    fn an_answered_ping_does_prove_dispatch_reachability() -> TestResult {
1415        let start = Instant::now();
1416        let (tracker, worker) = tracker_with_worker(start)?;
1417
1418        let much_later = start + WINDOW * 4;
1419        serve_probation(&tracker, worker, much_later)?;
1420
1421        assert!(
1422            tracker.is_dispatch_reachable(worker, much_later)?,
1423            "answered pings are the one thing that proves the push leg works"
1424        );
1425        assert!(
1426            tracker.unreachable_workers(much_later)?.is_empty(),
1427            "a worker answering pings is never unreachable"
1428        );
1429        Ok(())
1430    }
1431
1432    /// Registration opens a PROBATION and grants nothing. The handshake ack is
1433    /// SENT by this server; nothing reports that it was RECEIVED, so a
1434    /// connection is a channel, not proof that the channel carries. Eligibility
1435    /// is earned by answered pings only.
1436    #[test]
1437    fn registration_opens_a_probation_and_does_not_grant_eligibility() -> TestResult {
1438        let start = Instant::now();
1439        let (tracker, worker) = tracker_with_worker(start)?;
1440
1441        assert!(
1442            !tracker.is_dispatch_reachable(worker, start)?,
1443            "a brand-new connection has proved nothing about the push leg"
1444        );
1445        assert_eq!(
1446            tracker.unreachable_workers(start)?,
1447            vec![ExcludedWorker {
1448                worker_id: worker,
1449                // And it is carried as a PROBATION, not as a reachability
1450                // failure. This is the distinction that stopped an ordinary
1451                // worker start from being announced to the operator as an
1452                // unreachable dispatch path.
1453                exclusion: DispatchExclusion::OpeningProbation { answers: 0 },
1454            }],
1455            "a worker serving its probation is carried in the census as unreachable"
1456        );
1457        Ok(())
1458    }
1459
1460    /// The case that produced a FALSE ALARM on every healthy worker start.
1461    ///
1462    /// One answer banked out of two: the server has demonstrably reached this
1463    /// worker — moments ago — and is merely waiting for the second consecutive
1464    /// answer. Reporting that as a reachability failure told Tom's operator log
1465    /// his worker's dispatch path was dead when the opposite had just been
1466    /// measured. The exclusion is real; the REASON is an opening probation.
1467    #[test]
1468    fn a_part_served_probation_is_a_probation_and_not_a_reachability_failure() -> TestResult {
1469        let start = Instant::now();
1470        let (tracker, worker) = tracker_with_worker(start)?;
1471        const {
1472            assert!(
1473                DISPATCH_PROBATION_PINGS > 1,
1474                "this test is only meaningful while the probation takes more than one answer"
1475            );
1476        }
1477        assert!(tracker.record_dispatch_reachability(worker, start)?);
1478
1479        assert_eq!(
1480            tracker.unreachable_workers(start)?,
1481            vec![ExcludedWorker {
1482                worker_id: worker,
1483                exclusion: DispatchExclusion::OpeningProbation { answers: 1 },
1484            }],
1485            "a worker that has answered part of its opening probation is still excluded, but it \
1486             must not be described as one the server cannot reach — it answered"
1487        );
1488        Ok(())
1489    }
1490
1491    /// The other side of the same discrimination, and the control for the test
1492    /// above: once eligibility has actually been HELD, losing it is an incident
1493    /// and must classify differently. Without this, a classifier that answered
1494    /// `OpeningProbation` unconditionally would satisfy the test above.
1495    #[test]
1496    fn losing_held_eligibility_is_reported_as_a_loss_not_as_a_fresh_probation() -> TestResult {
1497        let start = Instant::now();
1498        let (tracker, worker) = tracker_with_worker(start)?;
1499        serve_probation(&tracker, worker, start)?;
1500        assert!(
1501            tracker.is_dispatch_reachable(worker, start)?,
1502            "precondition: eligibility was genuinely held before it was lost"
1503        );
1504
1505        assert!(
1506            tracker.record_dispatch_unreachable(worker)?,
1507            "the worker is still tracked when its ping fails"
1508        );
1509
1510        assert_eq!(
1511            tracker.unreachable_workers(start)?,
1512            vec![ExcludedWorker {
1513                worker_id: worker,
1514                exclusion: DispatchExclusion::ReachabilityLost,
1515            }],
1516            "a failed ping on a worker that HAD eligibility is an incident, and must not be \
1517             filed as the ordinary probation every fresh connection serves"
1518        );
1519        Ok(())
1520    }
1521
1522    /// A REDIAL is a new measurement. The previous connection's proof must not
1523    /// make the new connection's ordinary probation look like an incident —
1524    /// otherwise every reconnect of a healthy worker would raise the alarm that
1525    /// is supposed to mean something has gone wrong.
1526    #[test]
1527    fn a_reconnect_starts_a_fresh_probation_not_a_lost_eligibility() -> TestResult {
1528        let start = Instant::now();
1529        let (tracker, worker) = tracker_with_worker(start)?;
1530        serve_probation(&tracker, worker, start)?;
1531
1532        tracker.unregister_connection(worker)?;
1533        tracker.register_connection(worker, start)?;
1534
1535        assert_eq!(
1536            tracker.unreachable_workers(start)?,
1537            vec![ExcludedWorker {
1538                worker_id: worker,
1539                exclusion: DispatchExclusion::OpeningProbation { answers: 0 },
1540            }],
1541            "nothing earned on the old connection carries across to the new one"
1542        );
1543        Ok(())
1544    }
1545
1546    /// The probation must be SERVED IN FULL. One answered ping can be luck — a
1547    /// link that answers one probe in three would otherwise accrue eligibility
1548    /// and then flap. This pins the boundary from below: K-1 answers is not
1549    /// enough, and the very next one is.
1550    #[test]
1551    fn one_ping_short_of_the_probation_earns_nothing() -> TestResult {
1552        let start = Instant::now();
1553        let (tracker, worker) = tracker_with_worker(start)?;
1554
1555        for _ in 0..DISPATCH_PROBATION_PINGS - 1 {
1556            assert!(tracker.record_dispatch_reachability(worker, start)?);
1557            assert!(
1558                !tracker.is_dispatch_reachable(worker, start)?,
1559                "eligibility must not be granted before the probation is served in full"
1560            );
1561        }
1562
1563        assert!(tracker.record_dispatch_reachability(worker, start)?);
1564        assert!(
1565            tracker.is_dispatch_reachable(worker, start)?,
1566            "the ping that completes the probation must grant eligibility — otherwise this test \
1567             would pass on a tracker that never grants it at all"
1568        );
1569        Ok(())
1570    }
1571
1572    /// A failed probe RESETS the run. Eligibility is withdrawn immediately, not
1573    /// when the window later expires: an unanswered probe is direct evidence
1574    /// about the push leg, and direct negative evidence must weigh at least as
1575    /// much as silence.
1576    #[test]
1577    fn a_failed_probe_withdraws_eligibility_at_once_and_restarts_the_probation() -> TestResult {
1578        let start = Instant::now();
1579        let (tracker, worker) = tracker_with_worker(start)?;
1580        serve_probation(&tracker, worker, start)?;
1581        assert!(
1582            tracker.is_dispatch_reachable(worker, start)?,
1583            "precondition"
1584        );
1585
1586        assert!(
1587            tracker.record_dispatch_unreachable(worker)?,
1588            "the worker is still tracked"
1589        );
1590        assert!(
1591            !tracker.is_dispatch_reachable(worker, start)?,
1592            "a failed probe withdraws eligibility on the spot, inside the window"
1593        );
1594
1595        // And the run restarts from zero rather than resuming: one answer does
1596        // not restore what a full probation earned.
1597        assert!(tracker.record_dispatch_reachability(worker, start)?);
1598        assert!(
1599            !tracker.is_dispatch_reachable(worker, start)?,
1600            "a single answer after a failure must not restore eligibility"
1601        );
1602        Ok(())
1603    }
1604
1605    /// 🔴 THE FLAPPING PIN. A link that answers every other probe must NEVER
1606    /// become eligible. Cumulative counting would let it accrue, and eligibility
1607    /// would switch on and off under a running fleet — the intermittent evidence
1608    /// that costs hours to attribute. Consecutiveness is what forbids it.
1609    #[test]
1610    fn a_link_that_answers_every_other_probe_never_becomes_eligible() -> TestResult {
1611        let start = Instant::now();
1612        let (tracker, worker) = tracker_with_worker(start)?;
1613
1614        // Far more probes than the probation demands, alternating.
1615        for probe in 0..DISPATCH_PROBATION_PINGS * 10 {
1616            let now = start + Duration::from_millis(u64::from(probe));
1617            if probe % 2 == 0 {
1618                assert!(tracker.record_dispatch_reachability(worker, now)?);
1619            } else {
1620                assert!(tracker.record_dispatch_unreachable(worker)?);
1621            }
1622            assert!(
1623                !tracker.is_dispatch_reachable(worker, now)?,
1624                "a flapping link must never hold dispatch eligibility, at any probe (probe {probe})"
1625            );
1626        }
1627
1628        // The control: the same worker, answering consecutively, DOES become
1629        // eligible — so this test cannot pass on a tracker that grants nothing.
1630        let now = start + Duration::from_secs(1);
1631        serve_probation(&tracker, worker, now)?;
1632        assert!(
1633            tracker.is_dispatch_reachable(worker, now)?,
1634            "consecutive answers must still earn eligibility"
1635        );
1636        Ok(())
1637    }
1638
1639    /// Reachability must EXPIRE on its own clock. If it were only ever advanced
1640    /// and never allowed to go stale, eligibility could never be withdrawn.
1641    #[test]
1642    fn reachability_goes_stale_once_the_window_passes() -> TestResult {
1643        let start = Instant::now();
1644        let (tracker, worker) = tracker_with_worker(start)?;
1645        serve_probation(&tracker, worker, start)?;
1646
1647        assert!(
1648            tracker.is_dispatch_reachable(worker, start + WINDOW)?,
1649            "still inside the window"
1650        );
1651        assert!(
1652            !tracker.is_dispatch_reachable(worker, start + WINDOW + Duration::from_millis(1))?,
1653            "one millisecond past the window is stale"
1654        );
1655        Ok(())
1656    }
1657
1658    /// An untracked worker is not reachable: absence of proof is not proof. A
1659    /// pong racing a reap must not resurrect it either.
1660    #[test]
1661    fn an_unregistered_worker_is_never_reachable_and_cannot_be_resurrected() -> TestResult {
1662        let start = Instant::now();
1663        let (tracker, worker) = tracker_with_worker(start)?;
1664        tracker.unregister_connection(worker)?;
1665
1666        assert!(
1667            !tracker.is_dispatch_reachable(worker, start)?,
1668            "a deregistered worker is not reachable"
1669        );
1670        assert!(
1671            !tracker.record_dispatch_reachability(worker, start)?,
1672            "a late pong must not resurrect a deregistered worker"
1673        );
1674        assert!(
1675            !tracker.record_dispatch_unreachable(worker)?,
1676            "a late probe FAILURE must not resurrect a deregistered worker either — the reset \
1677             path allocates an entry, so it has to refuse an untracked worker as firmly as the \
1678             success path does"
1679        );
1680        assert!(
1681            tracker.unreachable_workers(start)?.is_empty(),
1682            "an untracked worker is not carried in the census either"
1683        );
1684        Ok(())
1685    }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690    use std::sync::Mutex;
1691
1692    use aion_core::ContentType;
1693    use aion_proto::{ProtoActivityId, ProtoPayload, ProtoWorkflowId};
1694    use serde_json::json;
1695    use uuid::Uuid;
1696
1697    use crate::worker::registry::WorkerRegistration;
1698
1699    use super::*;
1700
1701    /// aion#204 D1: the reservation→tracker handover never publishes a count
1702    /// higher than the work the worker is holding.
1703    ///
1704    /// The handover used to be two acts under two separate registry locks —
1705    /// `track_task` incremented, then the caller dropped the reservation and
1706    /// decremented — so between them `in_flight` read ONE HIGHER than the truth.
1707    /// `reserve_worker` refuses on `held >= advertised`, so a concurrent leg
1708    /// landing in that window was told the pool was full when it was not.
1709    /// Measured in the field at `in_flight = 5` on a worker advertising 4, on a
1710    /// fan sized exactly to its pool — where the margin is zero and one
1711    /// collision is enough.
1712    ///
1713    /// One dispatch is in flight at a time here, so a correct handover can never
1714    /// publish more than 1. An observer hammers the count throughout; under the
1715    /// old two-act handover it sees 2. The observer is a free-running thread, so
1716    /// under a loaded suite it may not be scheduled during the microseconds a
1717    /// round holds its slot and see nothing at all — a peak of 0 is a starved
1718    /// observer, not a finding (measured 2026-09-01 on a 16-core box: 2 of 4
1719    /// full-suite runs). So the two halves are pinned separately: the observer
1720    /// proves the count never exceeds 1, and the round itself reads the count
1721    /// after its handover and requires exactly 1 — the published truth.
1722    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1723    async fn the_handover_never_publishes_a_count_above_the_real_work() -> Result<(), ServerError> {
1724        /// Enough rounds that the two-lock window is hit; it is microseconds
1725        /// wide, so a handful would prove nothing.
1726        const ROUNDS: usize = 400;
1727
1728        let worker = RegisteredTestWorker::connected()?;
1729        let tracker = HeartbeatTracker::new(Duration::from_secs(30));
1730        let registry = worker.registry.clone();
1731        let worker_id = worker.worker_id;
1732
1733        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1734        let peak = Arc::new(std::sync::atomic::AtomicU32::new(0));
1735        let observer = {
1736            let (registry, stop, peak) = (registry.clone(), Arc::clone(&stop), Arc::clone(&peak));
1737            std::thread::spawn(move || {
1738                while !stop.load(std::sync::atomic::Ordering::SeqCst) {
1739                    if let Ok(held) = registry.in_flight_for_worker(worker_id) {
1740                        peak.fetch_max(held, std::sync::atomic::Ordering::SeqCst);
1741                    }
1742                }
1743            })
1744        };
1745
1746        for round in 0..ROUNDS {
1747            let activity_id = activity_id(u64::try_from(round).unwrap_or(0));
1748            let reservation = registry
1749                .reserve_worker(worker_id)?
1750                .ok_or_else(|| wire_error("a worker with a free slot must reserve"))?;
1751            tracker.track_task(
1752                worker_id,
1753                InFlightActivity {
1754                    workflow_id: workflow_id(),
1755                    activity_id: activity_id.clone(),
1756                    attempt: 1,
1757                    completion_token: crate::worker::CompletionToken::for_test(),
1758                },
1759                Instant::now(),
1760                &registry,
1761                Some(reservation),
1762            )?;
1763            assert_eq!(
1764                registry.in_flight_for_worker(worker_id)?,
1765                1,
1766                "after the handover the count must read exactly the one dispatch in flight"
1767            );
1768            assert!(tracker.complete_task(worker_id, &workflow_id(), &activity_id, &registry)?);
1769        }
1770
1771        stop.store(true, std::sync::atomic::Ordering::SeqCst);
1772        observer
1773            .join()
1774            .map_err(|_| wire_error("the observing thread panicked"))?;
1775
1776        let observed_peak = peak.load(std::sync::atomic::Ordering::SeqCst);
1777        assert!(
1778            observed_peak <= 1,
1779            "the count passed through {observed_peak}, higher than the one dispatch in flight; a \
1780             concurrent selection landing in that window is refused a slot the worker has"
1781        );
1782        assert_eq!(
1783            registry.in_flight_for_worker(worker_id)?,
1784            0,
1785            "and every round must have given its slot back"
1786        );
1787        Ok(())
1788    }
1789
1790    /// Item 9: tracking ONE dispatch twice must not spend TWO slots.
1791    ///
1792    /// `state.tasks` is keyed by `(worker, workflow, activity)` and an insert
1793    /// over a live key OVERWRITES — one entry before, one entry after — while
1794    /// the registry's capacity count increments. Ungated, tracking the same key
1795    /// twice without an intervening `complete_task` therefore counted twice and
1796    /// could only ever be decremented once, so the worker's usable capacity
1797    /// shrank by one permanently: the entry is cleared only when the worker
1798    /// deregisters.
1799    ///
1800    /// The release half is the vacuity control. If the second track had been
1801    /// dropped entirely rather than merely not double-counted, one completion
1802    /// would still have to return the count to zero — and it does.
1803    #[tokio::test]
1804    async fn tracking_one_dispatch_twice_spends_one_slot() -> Result<(), ServerError> {
1805        let worker = RegisteredTestWorker::connected()?;
1806        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
1807        let workflow_id = workflow_id();
1808        let activity_id = activity_id(21);
1809        let now = Instant::now();
1810        let task = || InFlightActivity {
1811            workflow_id: workflow_id.clone(),
1812            activity_id: activity_id.clone(),
1813            attempt: 1,
1814            completion_token: crate::worker::CompletionToken::for_test(),
1815        };
1816
1817        tracker.track_task(worker.worker_id, task(), now, &worker.registry, None)?;
1818        tracker.track_task(worker.worker_id, task(), now, &worker.registry, None)?;
1819        assert_eq!(
1820            worker.registry.in_flight_for_worker(worker.worker_id)?,
1821            1,
1822            "one tracked dispatch is one spent slot however many times it is tracked; counting \
1823             the redelivery again leaks a slot for the life of the registration"
1824        );
1825
1826        assert!(tracker.complete_task(
1827            worker.worker_id,
1828            &workflow_id,
1829            &activity_id,
1830            &worker.registry
1831        )?);
1832        assert_eq!(
1833            worker.registry.in_flight_for_worker(worker.worker_id)?,
1834            0,
1835            "and one completion must return the slot: the counts agree on how many times one \
1836             dispatch counts"
1837        );
1838        Ok(())
1839    }
1840
1841    #[derive(Default)]
1842    struct RecordingSink {
1843        completions: Mutex<Vec<ActivityCompletion>>,
1844        parks: Mutex<Vec<(WorkflowId, ActivityId)>>,
1845    }
1846
1847    impl ActivityCompletionSink for RecordingSink {
1848        fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
1849            self.completions
1850                .lock()
1851                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1852                .push(completion);
1853            Ok(())
1854        }
1855
1856        fn park_activity(
1857            &self,
1858            workflow_id: &WorkflowId,
1859            activity_id: &ActivityId,
1860        ) -> Result<(), ServerError> {
1861            self.parks
1862                .lock()
1863                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1864                .push((workflow_id.clone(), activity_id.clone()));
1865            Ok(())
1866        }
1867    }
1868
1869    fn workflow_id() -> WorkflowId {
1870        WorkflowId::new(Uuid::nil())
1871    }
1872
1873    fn activity_id(position: u64) -> ActivityId {
1874        ActivityId::from_sequence_position(position)
1875    }
1876
1877    fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
1878        Ok(Payload::from_json(value)?)
1879    }
1880
1881    fn heartbeat(
1882        workflow_id: WorkflowId,
1883        activity_id: ActivityId,
1884        progress: Option<Payload>,
1885    ) -> ProtoHeartbeat {
1886        ProtoHeartbeat {
1887            workflow_id: Some(ProtoWorkflowId::from(workflow_id)),
1888            activity_id: Some(ProtoActivityId::from(activity_id)),
1889            progress: progress.map(ProtoPayload::from),
1890        }
1891    }
1892
1893    /// A registered worker holding everything that keeps it ALIVE: the
1894    /// registry it is in, the registration guard, its worker id, and — the
1895    /// input the expiry sweep now reads — the stream receiver whose presence
1896    /// is the connected fact.
1897    ///
1898    /// The receiver is held rather than dropped at construction because the
1899    /// two are now different worlds. A worker with a live receiver is
1900    /// CONNECTED, and silence from it means busy or wedged, never gone; one
1901    /// whose receiver has been dropped has no push leg left, and silence from
1902    /// it means exactly what the sweep used to assume of both. A test that
1903    /// wants the second says so with [`Self::disconnect`], instead of getting
1904    /// it for free from a helper that happened to drop the receiver.
1905    struct RegisteredTestWorker {
1906        registry: ConnectedWorkerRegistry,
1907        /// Held for its Drop: dropping it deregisters the worker.
1908        _registration: WorkerRegistration,
1909        /// `None` once the push leg has been closed on purpose.
1910        receiver: Option<tokio::sync::mpsc::Receiver<crate::worker::registry::WorkerMessage>>,
1911        worker_id: WorkerId,
1912    }
1913
1914    impl RegisteredTestWorker {
1915        /// A worker registered and CONNECTED: the server still holds an open
1916        /// channel to it.
1917        fn connected() -> Result<Self, ServerError> {
1918            let registry = ConnectedWorkerRegistry::default();
1919            let (tx, rx) = tokio::sync::mpsc::channel(1);
1920            let activity_types = [String::from("charge-card")];
1921            let registration = registry.register(
1922                "tenant-a",
1923                activity_types.iter(),
1924                tx,
1925                crate::worker::UNBOUNDED_SENDER_WORKER_CONCURRENCY,
1926            )?;
1927            let worker_id = registration
1928                .worker_id()
1929                .ok_or_else(|| ServerError::lock_poisoned("test worker registration"))?;
1930            Ok(Self {
1931                registry,
1932                _registration: registration,
1933                receiver: Some(rx),
1934                worker_id,
1935            })
1936        }
1937
1938        /// Close the push leg without touching the registration — the shape of
1939        /// a worker whose process is genuinely gone while the server has not
1940        /// noticed yet.
1941        fn disconnect(&mut self) {
1942            self.receiver = None;
1943        }
1944    }
1945
1946    #[test]
1947    fn heartbeat_refresh_keeps_task_live_across_window() -> Result<(), Box<dyn std::error::Error>> {
1948        let window = Duration::from_secs(5);
1949        let tracker = HeartbeatTracker::new(window);
1950        let worker = RegisteredTestWorker::connected()?;
1951        let (registry, worker_id) = (&worker.registry, worker.worker_id);
1952        let workflow_id = workflow_id();
1953        let activity_id = activity_id(10);
1954        let start = Instant::now();
1955
1956        tracker.track_task(
1957            worker_id,
1958            InFlightActivity {
1959                workflow_id: workflow_id.clone(),
1960                activity_id: activity_id.clone(),
1961                attempt: 1,
1962                completion_token: crate::worker::CompletionToken::for_test(),
1963            },
1964            start,
1965            registry,
1966            None,
1967        )?;
1968        assert!(tracker.is_live(worker_id, &workflow_id, &activity_id, start + window)?);
1969
1970        let progress = payload(&json!({"percent": 50}))?;
1971        let update = tracker.record_heartbeat(
1972            worker_id,
1973            heartbeat(
1974                workflow_id.clone(),
1975                activity_id.clone(),
1976                Some(progress.clone()),
1977            ),
1978            start + window,
1979        )?;
1980
1981        assert_eq!(update.liveness.last_progress, Some(progress));
1982        assert!(tracker.is_live(
1983            worker_id,
1984            &workflow_id,
1985            &activity_id,
1986            start + window + window
1987        )?);
1988        assert!(
1989            tracker
1990                .expired_workers(registry, start + window + window)?
1991                .is_empty()
1992        );
1993        Ok(())
1994    }
1995
1996    #[test]
1997    fn missed_heartbeat_deregisters_worker_and_fails_in_flight_once()
1998    -> Result<(), Box<dyn std::error::Error>> {
1999        let mut worker = RegisteredTestWorker::connected()?;
2000        // This sweep's subject is a worker that is GONE, so its push leg is
2001        // closed on purpose: a worker whose channel is still open and whose
2002        // silence is merely saturation is no longer reaped, and a test that
2003        // left the leg open would be asserting the behaviour this landing
2004        // removes.
2005        worker.disconnect();
2006        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2007        let sink = RecordingSink::default();
2008        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2009        let workflow_id = workflow_id();
2010        let activity_id = activity_id(11);
2011        let start = Instant::now();
2012
2013        // REGISTERED, explicitly. `track_task` used to create this entry as a
2014        // side effect, which meant the server's own act of dispatching started
2015        // the clock it later read to decide whether the worker was still there.
2016        // It no longer does, so the connection is recorded where it actually
2017        // happens in production: when the worker registers, before anything is
2018        // dispatched to it.
2019        tracker.register_connection(worker_id, start)?;
2020        tracker.track_task(
2021            worker_id,
2022            InFlightActivity {
2023                workflow_id: workflow_id.clone(),
2024                activity_id: activity_id.clone(),
2025                attempt: 1,
2026                completion_token: crate::worker::CompletionToken::for_test(),
2027            },
2028            start,
2029            registry,
2030            None,
2031        )?;
2032
2033        let reports =
2034            tracker.fail_expired_workers(registry, &sink, start + Duration::from_secs(6))?;
2035        assert_eq!(reports.len(), 1);
2036        assert_eq!(reports[0].worker_id, worker_id);
2037        assert_eq!(reports[0].tasks.len(), 1);
2038        assert!(
2039            registry
2040                .workers_for("tenant-a", "default", "charge-card", None)?
2041                .is_empty()
2042        );
2043
2044        let second = tracker.fail_disconnected_worker(worker_id, registry, &sink)?;
2045        assert!(second.tasks.is_empty());
2046        let completions = sink
2047            .completions
2048            .lock()
2049            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2050        assert_eq!(completions.len(), 1);
2051        assert_eq!(completions[0].workflow_id, workflow_id);
2052        assert_eq!(completions[0].activity_id, activity_id);
2053        // The sweep reports a TRANSPORT-domain loss, not an activity failure:
2054        // the activity never executed to a result, so the sink (not this sweep)
2055        // classifies it and applies the transport's own re-dispatch budget.
2056        // Before this distinction existed the sweep synthesized a `Retryable`
2057        // `ActivityError` that the engine then delivered as a TERMINAL failure
2058        // whenever the activity carried no authored retry policy.
2059        match &completions[0].outcome {
2060            ActivityCompletionOutcome::WorkerLost { worker_id: lost } => {
2061                assert_eq!(*lost, worker_id);
2062            }
2063            other => {
2064                return Err(format!("expected a lost-worker outcome, got {other:?}").into());
2065            }
2066        }
2067        Ok(())
2068    }
2069
2070    #[test]
2071    fn disconnected_worker_fails_each_in_flight_task_once() -> Result<(), Box<dyn std::error::Error>>
2072    {
2073        let mut worker = RegisteredTestWorker::connected()?;
2074        // This sweep's subject is a worker that is GONE, so its push leg is
2075        // closed on purpose: a worker whose channel is still open and whose
2076        // silence is merely saturation is no longer reaped, and a test that
2077        // left the leg open would be asserting the behaviour this landing
2078        // removes.
2079        worker.disconnect();
2080        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2081        let sink = RecordingSink::default();
2082        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2083        let workflow_id = workflow_id();
2084        let start = Instant::now();
2085
2086        tracker.track_task(
2087            worker_id,
2088            InFlightActivity {
2089                workflow_id: workflow_id.clone(),
2090                activity_id: activity_id(21),
2091                attempt: 1,
2092                completion_token: crate::worker::CompletionToken::for_test(),
2093            },
2094            start,
2095            registry,
2096            None,
2097        )?;
2098        tracker.track_task(
2099            worker_id,
2100            InFlightActivity {
2101                workflow_id,
2102                activity_id: activity_id(22),
2103                attempt: 1,
2104                completion_token: crate::worker::CompletionToken::for_test(),
2105            },
2106            start,
2107            registry,
2108            None,
2109        )?;
2110
2111        let report = tracker.fail_disconnected_worker(worker_id, registry, &sink)?;
2112        assert_eq!(report.tasks.len(), 2);
2113        assert!(
2114            registry
2115                .workers_for("tenant-a", "default", "charge-card", None)?
2116                .is_empty()
2117        );
2118
2119        let completions = sink
2120            .completions
2121            .lock()
2122            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2123        assert_eq!(completions.len(), 2);
2124        assert!(completions.iter().all(|completion| matches!(
2125            &completion.outcome,
2126            ActivityCompletionOutcome::WorkerLost { .. }
2127        )));
2128        Ok(())
2129    }
2130
2131    /// #207: parking a drain-disconnected worker removes its tasks, deregisters
2132    /// it, and PARKS each task through the sink — zero completions synthesized,
2133    /// so the durable log stays byte-equivalent to a kill -9. A second park (or
2134    /// a later fail sweep) finds nothing: the idempotent-deregister discipline
2135    /// the fail path already proves holds for parks too.
2136    #[test]
2137    fn park_disconnected_worker_parks_tasks_without_synthesizing_completions()
2138    -> Result<(), Box<dyn std::error::Error>> {
2139        let mut worker = RegisteredTestWorker::connected()?;
2140        // This sweep's subject is a worker that is GONE, so its push leg is
2141        // closed on purpose: a worker whose channel is still open and whose
2142        // silence is merely saturation is no longer reaped, and a test that
2143        // left the leg open would be asserting the behaviour this landing
2144        // removes.
2145        worker.disconnect();
2146        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2147        let sink = RecordingSink::default();
2148        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2149        let workflow_id = workflow_id();
2150        let start = Instant::now();
2151        tracker.track_task(
2152            worker_id,
2153            InFlightActivity {
2154                workflow_id: workflow_id.clone(),
2155                activity_id: activity_id(60),
2156                attempt: 1,
2157                completion_token: crate::worker::CompletionToken::for_test(),
2158            },
2159            start,
2160            registry,
2161            None,
2162        )?;
2163        tracker.track_task(
2164            worker_id,
2165            InFlightActivity {
2166                workflow_id: workflow_id.clone(),
2167                activity_id: activity_id(61),
2168                attempt: 1,
2169                completion_token: crate::worker::CompletionToken::for_test(),
2170            },
2171            start,
2172            registry,
2173            None,
2174        )?;
2175
2176        let report = tracker.park_disconnected_worker(worker_id, registry, &sink)?;
2177        assert_eq!(report.tasks.len(), 2);
2178        assert_eq!(
2179            tracker.in_flight_count()?,
2180            0,
2181            "parking must remove every tracked task so drain accounting reaches zero"
2182        );
2183        assert!(
2184            registry
2185                .workers_for("tenant-a", "default", "charge-card", None)?
2186                .is_empty(),
2187            "the parked worker must be deregistered from routing"
2188        );
2189        let parks = sink
2190            .parks
2191            .lock()
2192            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2193        assert_eq!(parks.len(), 2, "each task must be parked exactly once");
2194        drop(parks);
2195        assert!(
2196            sink.completions
2197                .lock()
2198                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2199                .is_empty(),
2200            "parking must never synthesize an activity completion"
2201        );
2202
2203        // Double-park and park-after-fail are no-ops: the idempotent core.
2204        let second = tracker.park_disconnected_worker(worker_id, registry, &sink)?;
2205        assert!(second.tasks.is_empty());
2206        let third = tracker.fail_disconnected_worker(worker_id, registry, &sink)?;
2207        assert!(third.tasks.is_empty());
2208        assert_eq!(
2209            sink.parks
2210                .lock()
2211                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2212                .len(),
2213            2,
2214            "re-sweeping a parked worker must park nothing further"
2215        );
2216        assert!(
2217            sink.completions
2218                .lock()
2219                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2220                .is_empty(),
2221            "a fail sweep after the park must fail nothing"
2222        );
2223        Ok(())
2224    }
2225
2226    /// #207 drain-timeout backstop: the bulk park removes every worker's tasks,
2227    /// parks each through the sink, and wakes drain waiters — never
2228    /// synthesizing a completion.
2229    #[tokio::test]
2230    async fn park_all_in_flight_workers_parks_everything_and_wakes_drain_waiters()
2231    -> Result<(), Box<dyn std::error::Error>> {
2232        let mut worker = RegisteredTestWorker::connected()?;
2233        // This sweep's subject is a worker that is GONE, so its push leg is
2234        // closed on purpose: a worker whose channel is still open and whose
2235        // silence is merely saturation is no longer reaped, and a test that
2236        // left the leg open would be asserting the behaviour this landing
2237        // removes.
2238        worker.disconnect();
2239        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2240        let sink = RecordingSink::default();
2241        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2242        let workflow_id = workflow_id();
2243        tracker.track_task(
2244            worker_id,
2245            InFlightActivity {
2246                workflow_id: workflow_id.clone(),
2247                activity_id: activity_id(70),
2248                attempt: 1,
2249                completion_token: crate::worker::CompletionToken::for_test(),
2250            },
2251            Instant::now(),
2252            registry,
2253            None,
2254        )?;
2255        // Arm a waiter on the tracker's empty notify BEFORE the bulk park.
2256        let notified = tracker.empty.notified();
2257        tokio::pin!(notified);
2258
2259        let reports = tracker.park_all_in_flight_workers(registry, &sink)?;
2260        assert_eq!(reports.len(), 1);
2261        assert_eq!(reports[0].worker_id, worker_id);
2262        assert_eq!(reports[0].tasks.len(), 1);
2263        assert_eq!(tracker.in_flight_count()?, 0);
2264        assert_eq!(
2265            sink.parks
2266                .lock()
2267                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2268                .len(),
2269            1
2270        );
2271        assert!(
2272            sink.completions
2273                .lock()
2274                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
2275                .is_empty(),
2276            "the bulk park must never synthesize a completion"
2277        );
2278        tokio::time::timeout(Duration::from_millis(200), notified)
2279            .await
2280            .map_err(|_| "the bulk park must wake drain waiters")?;
2281        Ok(())
2282    }
2283
2284    /// The worker runtime's AUTOMATIC liveness beats carry no payload and
2285    /// interleave with explicit handler progress heartbeats: a payload-free
2286    /// beat must refresh the liveness stamp WITHOUT erasing the handler's
2287    /// most recent progress report.
2288    #[test]
2289    fn payload_free_heartbeat_refreshes_liveness_without_clearing_progress()
2290    -> Result<(), Box<dyn std::error::Error>> {
2291        let window = Duration::from_secs(5);
2292        let tracker = HeartbeatTracker::new(window);
2293        let worker = RegisteredTestWorker::connected()?;
2294        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2295        let workflow_id = workflow_id();
2296        let activity_id = activity_id(12);
2297        let start = Instant::now();
2298
2299        tracker.track_task(
2300            worker_id,
2301            InFlightActivity {
2302                workflow_id: workflow_id.clone(),
2303                activity_id: activity_id.clone(),
2304                attempt: 1,
2305                completion_token: crate::worker::CompletionToken::for_test(),
2306            },
2307            start,
2308            registry,
2309            None,
2310        )?;
2311        let progress = payload(&json!({"percent": 80}))?;
2312        tracker.record_heartbeat(
2313            worker_id,
2314            heartbeat(
2315                workflow_id.clone(),
2316                activity_id.clone(),
2317                Some(progress.clone()),
2318            ),
2319            start + Duration::from_secs(1),
2320        )?;
2321
2322        // An automatic liveness beat: no payload, later timestamp.
2323        let update = tracker.record_heartbeat(
2324            worker_id,
2325            heartbeat(workflow_id.clone(), activity_id.clone(), None),
2326            start + Duration::from_secs(4),
2327        )?;
2328
2329        assert_eq!(
2330            update.liveness.last_progress,
2331            Some(progress),
2332            "a payload-free liveness beat must not erase handler progress"
2333        );
2334        assert!(
2335            tracker.is_live(
2336                worker_id,
2337                &workflow_id,
2338                &activity_id,
2339                start + Duration::from_secs(8)
2340            )?,
2341            "the payload-free beat must still refresh the liveness stamp"
2342        );
2343        Ok(())
2344    }
2345
2346    #[test]
2347    fn malformed_heartbeat_missing_ids_is_wire_error() -> Result<(), Box<dyn std::error::Error>> {
2348        let worker = RegisteredTestWorker::connected()?;
2349        let worker_id = worker.worker_id;
2350        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2351        let missing = ProtoHeartbeat {
2352            workflow_id: None,
2353            activity_id: Some(ProtoActivityId::from(activity_id(30))),
2354            progress: None,
2355        };
2356
2357        let result = tracker.record_heartbeat(worker_id, missing, Instant::now());
2358        assert!(matches!(result, Err(ServerError::Wire { .. })));
2359        Ok(())
2360    }
2361
2362    #[test]
2363    fn heartbeat_progress_is_not_reported_as_activity_result()
2364    -> Result<(), Box<dyn std::error::Error>> {
2365        let sink = RecordingSink::default();
2366        let worker = RegisteredTestWorker::connected()?;
2367        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2368        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2369        let workflow_id = workflow_id();
2370        let activity_id = activity_id(40);
2371        let now = Instant::now();
2372
2373        tracker.track_task(
2374            worker_id,
2375            InFlightActivity {
2376                workflow_id: workflow_id.clone(),
2377                activity_id: activity_id.clone(),
2378                attempt: 1,
2379                completion_token: crate::worker::CompletionToken::for_test(),
2380            },
2381            now,
2382            registry,
2383            None,
2384        )?;
2385        tracker.record_heartbeat(
2386            worker_id,
2387            heartbeat(
2388                workflow_id,
2389                activity_id,
2390                Some(Payload::new(
2391                    ContentType::Json,
2392                    b"{\"progress\":1}".to_vec(),
2393                )),
2394            ),
2395            now,
2396        )?;
2397
2398        let completions = sink
2399            .completions
2400            .lock()
2401            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
2402        assert!(completions.is_empty());
2403        Ok(())
2404    }
2405
2406    /// 🔴 aion#204's ARM ONE: a worker whose per-task beat has lapsed, whose
2407    /// push leg is OPEN, and whose dispatch path the server has PROVED it can
2408    /// reach, is NOT deregistered. It is busy.
2409    ///
2410    /// This is the case the sweep used to get wrong, and getting it wrong cost
2411    /// a thirty-seven minute outage. A gRPC worker running at its full
2412    /// concurrency stopped reading its stream, so its per-task beats lapsed;
2413    /// the sweep read that lapse as death, deregistered a connected worker
2414    /// holding live work, surfaced its in-flight activities as transport
2415    /// losses, and left the queue census reporting an empty pool that was
2416    /// never empty.
2417    ///
2418    /// `is_expired` is a PER-TASK predicate. One lapsed beat says something
2419    /// about one activity; it has never been a statement about the worker.
2420    #[test]
2421    fn a_reachable_connected_worker_is_not_reaped_for_a_lapsed_beat()
2422    -> Result<(), Box<dyn std::error::Error>> {
2423        let window = Duration::from_secs(5);
2424        let tracker = HeartbeatTracker::new(window);
2425        let worker = RegisteredTestWorker::connected()?;
2426        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2427        let start = Instant::now();
2428        tracker.register_connection(worker_id, start)?;
2429        tracker.track_task(
2430            worker_id,
2431            InFlightActivity {
2432                workflow_id: workflow_id(),
2433                activity_id: activity_id(60),
2434                attempt: 1,
2435                completion_token: crate::worker::CompletionToken::for_test(),
2436            },
2437            start,
2438            registry,
2439            None,
2440        )?;
2441        // The worker answers its probation, and keeps answering — which is what
2442        // a busy worker now does, because its receive loop no longer parks on
2443        // admission. The proof has a window of its own, so a probe answered
2444        // only once at the start would age out and this test would be
2445        // measuring staleness rather than saturation.
2446        let later = start + window + window;
2447        for answered_at in [start, later] {
2448            for _ in 0..DISPATCH_PROBATION_PINGS {
2449                assert!(tracker.record_dispatch_reachability(worker_id, answered_at)?);
2450            }
2451        }
2452
2453        // Well past the per-task window, and past the connection window too.
2454        assert!(
2455            tracker.expired_workers(registry, later)?.is_empty(),
2456            "a worker with an open push leg the server can PROVE it reaches is busy, not lost: \
2457             deregistering it is what emptied a pool that was never empty"
2458        );
2459        assert!(
2460            registry.is_registered(worker_id)?,
2461            "and it must still be in the registry, keeping its in-flight work"
2462        );
2463        Ok(())
2464    }
2465
2466    /// 🔴 aion#204's ARM TWO, and the reason the fix is not simply "stop
2467    /// reaping": a worker whose leg is open but which the server CANNOT prove
2468    /// it reaches is still reaped.
2469    ///
2470    /// This is #176's wedged process — stream open, nothing moving, in-flight
2471    /// work that will never complete — and an open `mpsc` sender says nothing
2472    /// about it. Gating the sweep on connection alone would have re-opened that
2473    /// failure while closing the other one, and on gRPC it would have disabled
2474    /// the sweep entirely: a sender closes only when the stream task ends, and
2475    /// that is already the teardown sweep's business.
2476    #[test]
2477    fn a_connected_worker_the_server_cannot_reach_is_still_reaped()
2478    -> Result<(), Box<dyn std::error::Error>> {
2479        let window = Duration::from_secs(5);
2480        let tracker = HeartbeatTracker::new(window);
2481        let worker = RegisteredTestWorker::connected()?;
2482        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2483        let start = Instant::now();
2484        tracker.register_connection(worker_id, start)?;
2485        tracker.track_task(
2486            worker_id,
2487            InFlightActivity {
2488                workflow_id: workflow_id(),
2489                activity_id: activity_id(61),
2490                attempt: 1,
2491                completion_token: crate::worker::CompletionToken::for_test(),
2492            },
2493            start,
2494            registry,
2495            None,
2496        )?;
2497        // No answered ping: the leg is open and nothing has come back on it.
2498        let later = start + window + window;
2499        assert_eq!(
2500            tracker.expired_workers(registry, later)?,
2501            vec![worker_id],
2502            "a wedged process holds a healthy-looking channel indefinitely; if silence on an \
2503             unproved leg did not reap, its in-flight work would wait for ever"
2504        );
2505        Ok(())
2506    }
2507
2508    /// The third arm, and the control for the first two: a worker with NO push
2509    /// leg left is reaped whatever its reachability history says.
2510    ///
2511    /// Without this, the first test would be equally satisfied by a sweep that
2512    /// had simply stopped reaping.
2513    #[test]
2514    fn a_disconnected_worker_is_reaped_even_after_proving_reachability()
2515    -> Result<(), Box<dyn std::error::Error>> {
2516        let window = Duration::from_secs(5);
2517        let tracker = HeartbeatTracker::new(window);
2518        let mut worker = RegisteredTestWorker::connected()?;
2519        let worker_id = worker.worker_id;
2520        let start = Instant::now();
2521        let later = start + window + window;
2522        tracker.register_connection(worker_id, start)?;
2523        // Proved, at `start` and NOT at the sweep instant — and that is forced,
2524        // not a preference. `record_dispatch_reachability` refreshes the
2525        // CONNECTION clock as well as the proof, so a worker proved at `later`
2526        // is not silent at `later` and is never nominated in the first place.
2527        // "Provably reachable right now AND overdue right now" is not a
2528        // reachable state: one call advances both clocks together.
2529        //
2530        // What this test can still say, and does, is that a worker which HAS
2531        // proved reachability is reaped once it goes quiet and its leg closes —
2532        // a past proof is not a standing acquittal.
2533        for _ in 0..DISPATCH_PROBATION_PINGS {
2534            assert!(tracker.record_dispatch_reachability(worker_id, start)?);
2535        }
2536        tracker.track_task(
2537            worker_id,
2538            InFlightActivity {
2539                workflow_id: workflow_id(),
2540                activity_id: activity_id(62),
2541                attempt: 1,
2542                completion_token: crate::worker::CompletionToken::for_test(),
2543            },
2544            start,
2545            &worker.registry,
2546            None,
2547        )?;
2548        // Still connected and still inside its window at `start + window`: the
2549        // connection clock has not lapsed, so nothing nominates it.
2550        assert!(
2551            tracker
2552                .expired_workers(&worker.registry, start + window)?
2553                .is_empty(),
2554            "a worker inside its window is not a candidate at all"
2555        );
2556
2557        worker.disconnect();
2558        assert_eq!(
2559            tracker.expired_workers(&worker.registry, later)?,
2560            vec![worker_id],
2561            "once the push leg is gone there is nothing left to hear a beat on, and the proof \
2562             that used to hold is about a link that no longer exists"
2563        );
2564        Ok(())
2565    }
2566
2567    /// `complete_task` reports whether THIS call retired the entry — the
2568    /// structural gate the liminal reply router uses to synthesize a
2569    /// lost-worker failure only for a dispatch nobody else resolved.
2570    #[test]
2571    fn complete_task_reports_whether_the_entry_was_tracked()
2572    -> Result<(), Box<dyn std::error::Error>> {
2573        let tracker = HeartbeatTracker::new(Duration::from_secs(5));
2574        let worker = RegisteredTestWorker::connected()?;
2575        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2576        let workflow_id = workflow_id();
2577        let id = activity_id(50);
2578        tracker.track_task(
2579            worker_id,
2580            InFlightActivity {
2581                workflow_id: workflow_id.clone(),
2582                activity_id: id.clone(),
2583                attempt: 1,
2584                completion_token: crate::worker::CompletionToken::for_test(),
2585            },
2586            Instant::now(),
2587            registry,
2588            None,
2589        )?;
2590
2591        assert!(tracker.is_tracked(worker_id, &workflow_id, &id)?);
2592        assert!(
2593            tracker.complete_task(worker_id, &workflow_id, &id, registry)?,
2594            "the first completion retires the tracked entry"
2595        );
2596        assert!(!tracker.is_tracked(worker_id, &workflow_id, &id)?);
2597        assert!(
2598            !tracker.complete_task(worker_id, &workflow_id, &id, registry)?,
2599            "a second completion finds nothing to retire"
2600        );
2601        Ok(())
2602    }
2603
2604    /// A liveness beat (the liminal worker's automatic pump) refreshes the
2605    /// task's expiry stamp — keeping a genuinely-running over-window activity
2606    /// out of the sweep — and reports an untracked task benignly.
2607    #[test]
2608    fn record_liveness_refreshes_stamp_and_ignores_untracked_tasks()
2609    -> Result<(), Box<dyn std::error::Error>> {
2610        let window = Duration::from_secs(5);
2611        let tracker = HeartbeatTracker::new(window);
2612        let worker = RegisteredTestWorker::connected()?;
2613        let (registry, worker_id) = (&worker.registry, worker.worker_id);
2614        let workflow_id = workflow_id();
2615        let id = activity_id(51);
2616        let start = Instant::now();
2617        tracker.track_task(
2618            worker_id,
2619            InFlightActivity {
2620                workflow_id: workflow_id.clone(),
2621                activity_id: id.clone(),
2622                attempt: 1,
2623                completion_token: crate::worker::CompletionToken::for_test(),
2624            },
2625            start,
2626            registry,
2627            None,
2628        )?;
2629
2630        // Beaten at the window edge, the task survives past the original expiry.
2631        assert!(tracker.record_liveness(worker_id, &workflow_id, &id, start + window)?);
2632        assert!(tracker.is_live(worker_id, &workflow_id, &id, start + window + window)?);
2633        assert!(
2634            tracker
2635                .expired_workers(registry, start + window + window)?
2636                .is_empty()
2637        );
2638
2639        // An untracked beat (an outbox dispatch, or a beat racing completion)
2640        // is a benign false, never an error.
2641        assert!(!tracker.record_liveness(
2642            worker_id,
2643            &workflow_id,
2644            &activity_id(52),
2645            start + window
2646        )?);
2647        Ok(())
2648    }
2649
2650    #[test]
2651    fn sweep_interval_is_quarter_window_clamped_to_one_second_and_window() {
2652        // The default 30s window sweeps every 7.5s (quarter-window).
2653        assert_eq!(
2654            sweep_interval(Duration::from_secs(30)),
2655            Duration::from_millis(7_500)
2656        );
2657        // A short window's quarter (500ms) is floored at 1s.
2658        assert_eq!(
2659            sweep_interval(Duration::from_secs(2)),
2660            Duration::from_secs(1)
2661        );
2662        // A very long window's quarter stays within the [1s, window] band.
2663        assert_eq!(
2664            sweep_interval(Duration::from_secs(3_600)),
2665            Duration::from_secs(900)
2666        );
2667        // A sub-second (test) window sweeps once per window, never spinning
2668        // sub-window nor waiting longer than the window itself.
2669        assert_eq!(
2670            sweep_interval(Duration::from_millis(200)),
2671            Duration::from_millis(200)
2672        );
2673        // A zero window is floored at the minimum positive period rather than
2674        // producing the zero interval `tokio::time::interval` rejects.
2675        assert_eq!(sweep_interval(Duration::ZERO), Duration::from_millis(1));
2676    }
2677}