Skip to main content

aion_worker/
context.rs

1//! `ActivityContext` heartbeat, cancellation, attempt, and identifier support.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use aion_core::{ActivityEvent, ActivityId, Payload, RunId, WorkflowId};
7use tokio::sync::{Notify, mpsc};
8
9use crate::error::WorkerError;
10
11/// Handler-facing context for one activity execution.
12#[derive(Clone, Debug)]
13pub struct ActivityContext {
14    /// The workflow this activity belongs to.
15    workflow_id: WorkflowId,
16    /// The concrete run this activity was dispatched by — the generation axis.
17    ///
18    /// REQUIRED, never optional: [`ActivityContext::run_id`] is the only source
19    /// a handler has for the run axis its transcript events must carry, and
20    /// [`aion_core::ActivityEvent::run_id`] is itself a required field. An
21    /// optional accessor would leave a handler with no honest way to build a
22    /// required field, so the run is supplied at construction or the context is
23    /// never built — the refusal lives at the dispatch boundary, not here.
24    run_id: RunId,
25    activity_id: ActivityId,
26    attempt: u32,
27    idempotency_key: Option<String>,
28    cancellation: Arc<CancellationState>,
29    heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
30    /// NOI-5b agent-observability event seam (additive, OPTIONAL). A running
31    /// activity (or the harness adapter driving it) emits neutral
32    /// [`ActivityEvent`]s here; the worker runtime drains them and forwards them
33    /// to the server's transcript sequencer over the same transport activity
34    /// results take. A context created WITHOUT this seam — every isolated unit
35    /// test and every activity that emits nothing — is a no-op, byte-identical to
36    /// today, exactly as the `heartbeat_sender` seam is.
37    event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
38}
39
40/// Internal handle used by the worker runtime to signal cooperative cancellation.
41#[derive(Clone, Debug)]
42pub struct ActivityCancellationHandle {
43    cancellation: Arc<CancellationState>,
44}
45
46/// Heartbeat request emitted by [`ActivityContext::heartbeat`].
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct HeartbeatRequest {
49    /// Workflow owning the activity whose progress is being reported.
50    pub workflow_id: WorkflowId,
51    /// Activity whose progress is being reported.
52    pub activity_id: ActivityId,
53    /// Opaque progress detail supplied by the handler.
54    pub detail: Option<Payload>,
55}
56
57#[derive(Debug)]
58struct CancellationState {
59    cancelled: AtomicBool,
60    notify: Notify,
61}
62
63impl ActivityContext {
64    /// Creates a context and the internal handle that can signal cancellation.
65    ///
66    /// The full dispatch identity is required: an activity execution always
67    /// belongs to one `(workflow, run, activity, attempt)`, and a handler reads
68    /// the workflow and run back to stamp the transcript events it emits.
69    #[must_use]
70    pub fn new(
71        workflow_id: WorkflowId,
72        run_id: RunId,
73        activity_id: ActivityId,
74        attempt: u32,
75    ) -> (Self, ActivityCancellationHandle) {
76        Self::for_workflow(workflow_id, run_id, activity_id, attempt, None)
77    }
78
79    /// Creates a context whose transcript seam is live, for a host that owns
80    /// the receiving end of `events`.
81    ///
82    /// This is the seam [`Self::emit_event`] publishes on: every event a handler
83    /// emits carries this context's `(workflow_id, run_id, activity_id, attempt)`
84    /// identity, which is exactly the key the server's transcript sequencer
85    /// files it under. A host that executes an activity IN PROCESS (the server's
86    /// declared-command path) uses this to hand its own publisher the same
87    /// stream a remote worker's drain would have delivered.
88    #[must_use]
89    pub fn with_transcript(
90        workflow_id: WorkflowId,
91        run_id: RunId,
92        activity_id: ActivityId,
93        attempt: u32,
94        events: mpsc::UnboundedSender<ActivityEvent>,
95    ) -> (Self, ActivityCancellationHandle) {
96        Self::for_workflow_with_events(
97            workflow_id,
98            run_id,
99            activity_id,
100            attempt,
101            None,
102            None,
103            Some(events),
104        )
105    }
106
107    /// Returns this activity's identifier.
108    #[must_use]
109    pub const fn activity_id(&self) -> &ActivityId {
110        &self.activity_id
111    }
112
113    /// Returns the workflow this activity belongs to.
114    #[must_use]
115    pub const fn workflow_id(&self) -> &WorkflowId {
116        &self.workflow_id
117    }
118
119    /// Returns this activity's attempt number.
120    #[must_use]
121    pub const fn attempt(&self) -> u32 {
122        self.attempt
123    }
124
125    /// Returns the concrete run this activity was dispatched by.
126    ///
127    /// A handler that emits transcript events through [`Self::emit_event`]
128    /// stamps this onto every [`ActivityEvent`] it builds: the transcript
129    /// keyspace is keyed on `(workflow, run, activity, attempt)`, and without
130    /// the run two generations of one continue-as-new chain write to the same
131    /// stream. Always present — the run is part of the dispatch identity, so
132    /// the handler is never handed an absence it cannot resolve.
133    #[must_use]
134    pub const fn run_id(&self) -> &RunId {
135        &self.run_id
136    }
137
138    /// Returns the stable external-effect key delivered with this task.
139    ///
140    /// Live worker tasks always return `Some`; manually constructed unit-test
141    /// contexts return `None` because they are not attached to a server task.
142    #[must_use]
143    pub fn idempotency_key(&self) -> Option<&str> {
144        self.idempotency_key.as_deref()
145    }
146
147    /// Emits a cooperative heartbeat request for this activity.
148    ///
149    /// This is the PROGRESS channel: handlers call it to attach a progress
150    /// payload to the activity's liveness record. LIVENESS itself is owned by
151    /// the worker runtime, which automatically heartbeats every in-flight
152    /// activity within the server-assigned heartbeat window — a handler that
153    /// never calls this still stays live for as long as it genuinely runs.
154    /// Contexts created without a live heartbeat sender remain no-op contexts
155    /// for isolated unit tests.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`WorkerError`] when an installed heartbeat seam has been closed.
160    pub fn heartbeat(&self, detail: Option<Payload>) -> Result<(), WorkerError> {
161        if let Some(sender) = &self.heartbeat_sender {
162            sender
163                .send(HeartbeatRequest {
164                    workflow_id: self.workflow_id.clone(),
165                    activity_id: self.activity_id.clone(),
166                    detail,
167                })
168                .map_err(|source| WorkerError::registration(HeartbeatSeamClosed { source }))?;
169        }
170        Ok(())
171    }
172
173    /// Emit a neutral agent-observability [`ActivityEvent`] onto the transcript
174    /// seam (NOI-5b).
175    ///
176    /// Additive and OPTIONAL: on a context created without a live event seam
177    /// (every isolated unit test, and every activity that does not run an
178    /// instrumented agent) this is a no-op returning `Ok(())`, so behaviour is
179    /// byte-identical to today. When a seam is installed the worker runtime drains
180    /// these events and forwards them to the server's transcript sequencer, which
181    /// stamps the commit-allocated `store_seq` — the producer never assigns it.
182    ///
183    /// Harness-neutral: the payload is a pure `aion-core` [`ActivityEvent`]; the
184    /// per-harness mapping lives in the worker-side adapter, never here.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`WorkerError`] when an installed event seam has been closed (the
189    /// runtime drain end was dropped) — a dropped transcript event is surfaced,
190    /// never silently swallowed.
191    pub fn emit_event(&self, event: ActivityEvent) -> Result<(), WorkerError> {
192        if let Some(sender) = &self.event_sender {
193            sender
194                .send(event)
195                .map_err(|source| WorkerError::registration(EventSeamClosed { source }))?;
196        }
197        Ok(())
198    }
199
200    /// Returns true once cooperative cancellation has been signalled.
201    #[must_use]
202    pub fn is_cancelled(&self) -> bool {
203        self.cancellation.cancelled.load(Ordering::Acquire)
204    }
205
206    /// Resolves when cooperative cancellation is signalled.
207    ///
208    /// # Why the waiter exists BEFORE the flag is read
209    ///
210    /// [`Notify::notify_waiters`] reaches the waiters that EXIST at the moment
211    /// it is called and stores nothing for a `Notified` created afterwards —
212    /// both halves measured in this module's
213    /// `a_notification_is_seen_by_a_waiter_that_existed_before_it_and_lost_on_one_that_did_not`.
214    /// A waiter that read the flag first and created its `Notified` second
215    /// therefore has a window: the flag reads false, the whole of
216    /// [`ActivityCancellationHandle::cancel`] runs inside the window, and the
217    /// notification is spent before this waiter exists to receive it. It is
218    /// then lost FOREVER, because cancellation is signalled exactly once — and
219    /// the awaiting side waits on a cancellation that has already happened,
220    /// which is a running command that cannot be killed.
221    ///
222    /// So the order is inverted. The `Notified` future is created and
223    /// `enable()`d — which registers this waiter explicitly, rather than
224    /// leaning on the creation-time capture alone — and only then is the flag
225    /// read. A `cancel` that lands before the read is seen by the read; one
226    /// that lands after it is seen by the waiter already in place. There is no
227    /// third position for it to land in.
228    ///
229    /// The loop is not a spin: `Notified` completes only on a notification, and
230    /// re-entering it creates and registers a new waiter before re-reading, so
231    /// every iteration keeps the same ordering.
232    pub async fn cancelled(&self) {
233        loop {
234            let notified = self.cancellation.notify.notified();
235            let mut notified = std::pin::pin!(notified);
236            // Registers this waiter. Everything after this line is covered by
237            // a notification, including the flag read on the next line.
238            notified.as_mut().enable();
239            if self.is_cancelled() {
240                return;
241            }
242            notified.await;
243        }
244    }
245
246    pub(crate) fn for_workflow(
247        workflow_id: WorkflowId,
248        run_id: RunId,
249        activity_id: ActivityId,
250        attempt: u32,
251        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
252    ) -> (Self, ActivityCancellationHandle) {
253        Self::for_workflow_with_events(
254            workflow_id,
255            run_id,
256            activity_id,
257            attempt,
258            None,
259            heartbeat_sender,
260            None,
261        )
262    }
263
264    pub(crate) fn for_task(
265        workflow_id: WorkflowId,
266        run_id: RunId,
267        activity_id: ActivityId,
268        attempt: u32,
269        idempotency_key: String,
270        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
271    ) -> (Self, ActivityCancellationHandle) {
272        Self::for_workflow_with_events(
273            workflow_id,
274            run_id,
275            activity_id,
276            attempt,
277            Some(idempotency_key),
278            heartbeat_sender,
279            None,
280        )
281    }
282
283    /// Build a context with BOTH the heartbeat seam and the NOI-5b transcript
284    /// event seam installed. The runtime uses this when an activity is driven with
285    /// observability enabled; the pre-existing constructors default the event seam
286    /// to `None` so every current call site is unchanged.
287    pub(crate) fn for_workflow_with_events(
288        workflow_id: WorkflowId,
289        run_id: RunId,
290        activity_id: ActivityId,
291        attempt: u32,
292        idempotency_key: Option<String>,
293        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
294        event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
295    ) -> (Self, ActivityCancellationHandle) {
296        let cancellation = Arc::new(CancellationState {
297            cancelled: AtomicBool::new(false),
298            notify: Notify::new(),
299        });
300        let context = Self {
301            workflow_id,
302            run_id,
303            activity_id,
304            attempt,
305            idempotency_key,
306            cancellation: Arc::clone(&cancellation),
307            heartbeat_sender,
308            event_sender,
309        };
310        let handle = ActivityCancellationHandle { cancellation };
311        (context, handle)
312    }
313}
314
315impl ActivityCancellationHandle {
316    /// Signals cooperative cancellation to the handler-facing context.
317    ///
318    /// The FLAG IS SET FIRST and the waiters woken second, and that order is
319    /// load-bearing in both directions: a waiter woken by this call finds the
320    /// flag already true (so [`ActivityContext::cancelled`] never wakes to a
321    /// flag that has not landed yet), and a waiter that registered before the
322    /// wake is woken by it (so the flag never lands with nobody told). Waking
323    /// first and setting second would let a woken waiter read `false`, loop,
324    /// and park forever on a notification that has already been spent.
325    ///
326    /// `notify_waiters` runs only on the 0→1 transition because cancellation
327    /// is signalled once: a second `cancel` has nothing new to say, and the
328    /// waiters it would wake are the ones already returned by the flag.
329    pub fn cancel(&self) {
330        let was_cancelled = self.cancellation.cancelled.swap(true, Ordering::AcqRel);
331        if !was_cancelled {
332            self.cancellation.notify.notify_waiters();
333        }
334    }
335}
336
337#[derive(Debug, thiserror::Error)]
338#[error("activity heartbeat seam is closed: {source}")]
339struct HeartbeatSeamClosed {
340    source: mpsc::error::SendError<HeartbeatRequest>,
341}
342
343#[derive(Debug, thiserror::Error)]
344#[error("activity transcript event seam is closed: {source}")]
345struct EventSeamClosed {
346    source: mpsc::error::SendError<ActivityEvent>,
347}
348
349#[cfg(test)]
350mod tests {
351    use std::future::Future as _;
352    use std::sync::Arc;
353    use std::sync::atomic::{AtomicBool, Ordering};
354    use std::task::{Context as TaskContext, Poll, Wake, Waker};
355    use std::time::Duration;
356
357    use aion_core::ActivityId;
358
359    use super::ActivityContext;
360
361    /// What a test returns. Every fallible step is carried rather than
362    /// unwrapped, because the workspace denies panicking accessors in test code
363    /// as firmly as in library code.
364    type TestResult = Result<(), Box<dyn std::error::Error>>;
365
366    /// NOI-5b: a context WITHOUT an event seam is a no-op — `emit_event` returns
367    /// `Ok(())` and drops the event, byte-identical to a context that predates the
368    /// seam. This is the additive guarantee: an activity that emits nothing (and
369    /// every isolated unit test) is unaffected.
370    #[tokio::test]
371    async fn emit_event_is_a_no_op_without_an_installed_seam() {
372        use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, RunId, WorkflowId};
373        use chrono::Utc;
374        use uuid::Uuid;
375
376        let workflow_id = WorkflowId::new(Uuid::from_u128(1));
377        let run_id = RunId::new(Uuid::from_u128(0x11));
378        let (context, _cancellation) = ActivityContext::new(
379            workflow_id.clone(),
380            run_id.clone(),
381            ActivityId::from_sequence_position(1),
382            0,
383        );
384        // The dispatch identity is always readable — a handler stamping a
385        // transcript event never has to invent the run axis.
386        assert_eq!(context.run_id(), &run_id);
387        assert_eq!(context.workflow_id(), &workflow_id);
388        let event = ActivityEvent {
389            workflow_id,
390            run_id,
391            activity_id: ActivityId::from_sequence_position(1),
392            attempt: 0,
393            agent_id: Uuid::from_u128(2),
394            agent_role: "orchestrator".to_owned(),
395            emitted_at: Utc::now(),
396            worker_seq: 1,
397            store_seq: None,
398            ephemeral: false,
399            kind: ActivityEventKind::Message {
400                role: MessageRole::Assistant,
401                text: "hello".to_owned(),
402            },
403        };
404        assert!(context.emit_event(event).is_ok());
405    }
406
407    /// With an event seam installed, `emit_event` forwards the neutral event to
408    /// the runtime drain end — the additive worker->server ingestion seam.
409    #[tokio::test]
410    async fn emit_event_forwards_to_installed_seam() -> Result<(), Box<dyn std::error::Error>> {
411        use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, RunId, WorkflowId};
412        use chrono::Utc;
413        use uuid::Uuid;
414
415        let run_id = RunId::new(Uuid::from_u128(0x11));
416        let (sender, mut drain) = super::mpsc::unbounded_channel();
417        let (context, _cancellation) = ActivityContext::for_workflow_with_events(
418            WorkflowId::new(Uuid::from_u128(1)),
419            run_id.clone(),
420            ActivityId::from_sequence_position(1),
421            0,
422            None,
423            None,
424            Some(sender),
425        );
426        // A dispatched context exposes the run its events must be stamped with.
427        assert_eq!(context.run_id(), &run_id);
428        let event = ActivityEvent {
429            workflow_id: WorkflowId::new(Uuid::from_u128(1)),
430            run_id: run_id.clone(),
431            activity_id: ActivityId::from_sequence_position(1),
432            attempt: 0,
433            agent_id: Uuid::from_u128(2),
434            agent_role: "orchestrator".to_owned(),
435            emitted_at: Utc::now(),
436            worker_seq: 7,
437            store_seq: None,
438            ephemeral: false,
439            kind: ActivityEventKind::Message {
440                role: MessageRole::Assistant,
441                text: "steer".to_owned(),
442            },
443        };
444        context.emit_event(event.clone())?;
445        let delivered = drain.recv().await.ok_or("event must be delivered")?;
446        assert_eq!(delivered.worker_seq, 7);
447        assert_eq!(delivered, event);
448        Ok(())
449    }
450
451    #[test]
452    fn live_task_context_exposes_the_server_idempotency_key() {
453        let run_id = aion_core::RunId::new_v4();
454        let (context, cancellation) = ActivityContext::for_task(
455            aion_core::WorkflowId::new_v4(),
456            run_id.clone(),
457            ActivityId::from_sequence_position(7),
458            3,
459            String::from("effect-key"),
460            None,
461        );
462
463        assert_eq!(context.idempotency_key(), Some("effect-key"));
464        assert_eq!(context.attempt(), 3);
465        assert_eq!(context.run_id(), &run_id);
466        drop(cancellation);
467    }
468
469    /// Reads the cancellation flag AT THE MOMENT it is woken, which is the only
470    /// place from which the ordering inside `cancel()` is observable: `wake` is
471    /// called from inside `notify_waiters`, so what the flag says here is what
472    /// it said before the wake was sent.
473    struct CancelWatcher {
474        context: ActivityContext,
475        woken: AtomicBool,
476        flag_at_wake: AtomicBool,
477    }
478
479    impl Wake for CancelWatcher {
480        fn wake(self: Arc<Self>) {
481            self.wake_by_ref();
482        }
483
484        fn wake_by_ref(self: &Arc<Self>) {
485            self.flag_at_wake
486                .store(self.context.is_cancelled(), Ordering::Release);
487            self.woken.store(true, Ordering::Release);
488        }
489    }
490
491    /// THE ORDERING GUARANTEE, both halves, measured rather than argued.
492    ///
493    /// A waiter that has polled once is REGISTERED (it is woken by a later
494    /// `cancel`), and `cancel` sets the flag BEFORE it wakes anyone (the flag
495    /// already reads true from inside the wake). Those two facts are what make
496    /// a cancellation impossible to lose: whichever side moves first, the other
497    /// sees it.
498    #[test]
499    fn cancel_sets_the_flag_before_it_wakes_a_registered_waiter() {
500        let (context, handle) = ActivityContext::new(
501            aion_core::WorkflowId::new_v4(),
502            aion_core::RunId::new_v4(),
503            ActivityId::from_sequence_position(1),
504            1,
505        );
506        let watcher = Arc::new(CancelWatcher {
507            context: context.clone(),
508            woken: AtomicBool::new(false),
509            flag_at_wake: AtomicBool::new(false),
510        });
511        let waker = Waker::from(Arc::clone(&watcher));
512        let mut task = TaskContext::from_waker(&waker);
513        let mut cancelled = std::pin::pin!(context.cancelled());
514
515        assert_eq!(
516            cancelled.as_mut().poll(&mut task),
517            Poll::Pending,
518            "nothing has cancelled yet"
519        );
520        assert!(
521            !watcher.woken.load(Ordering::Acquire),
522            "no wake before a cancel"
523        );
524
525        handle.cancel();
526
527        assert!(
528            watcher.woken.load(Ordering::Acquire),
529            "the waiter registered its interest on its first poll, so `cancel` found it"
530        );
531        assert!(
532            watcher.flag_at_wake.load(Ordering::Acquire),
533            "`cancel` must set the flag BEFORE it notifies: a waiter woken to a flag that has \
534             not landed yet loops and parks forever"
535        );
536        assert_eq!(
537            cancelled.as_mut().poll(&mut task),
538            Poll::Ready(()),
539            "the woken waiter resolves"
540        );
541    }
542
543    /// THE WINDOW THE FIX CLOSES, measured directly on the primitive rather
544    /// than asserted about it.
545    ///
546    /// Three arms, and the middle one is the whole reason the order in
547    /// `cancelled()` is what it is:
548    ///
549    /// * a `Notified` created AFTER a `notify_waiters` never sees it — the
550    ///   notification is gone, and cancellation is signalled exactly once, so a
551    ///   lost one is lost for good;
552    /// * a `Notified` created BEFORE it does see it, even though it had not yet
553    ///   been polled;
554    /// * and one that was additionally `enable`d sees it too.
555    ///
556    /// So the guarantee `cancelled()` needs is that the `Notified` EXISTS
557    /// before the flag is read. It is enabled as well, which registers it
558    /// explicitly at a point this code chooses, so the guarantee does not rest
559    /// on the creation-time capture alone.
560    #[tokio::test]
561    async fn a_notification_is_seen_by_a_waiter_that_existed_before_it_and_lost_on_one_that_did_not()
562    -> TestResult {
563        let notify = tokio::sync::Notify::new();
564        notify.notify_waiters();
565        let mut late = std::pin::pin!(notify.notified());
566        let missed = tokio::time::timeout(Duration::from_millis(200), late.as_mut()).await;
567        assert!(
568            missed.is_err(),
569            "a notification sent before the waiter existed is LOST — this is the window"
570        );
571
572        let notify = tokio::sync::Notify::new();
573        let mut created = std::pin::pin!(notify.notified());
574        notify.notify_waiters();
575        let landed = tokio::time::timeout(Duration::from_millis(200), created.as_mut()).await;
576        assert!(
577            landed.is_ok(),
578            "a waiter that existed when the notification was sent sees it"
579        );
580
581        let notify = tokio::sync::Notify::new();
582        let mut registered = std::pin::pin!(notify.notified());
583        assert!(
584            !registered.as_mut().enable(),
585            "nothing has been notified yet, so enabling only registers"
586        );
587        notify.notify_waiters();
588        let landed = tokio::time::timeout(Duration::from_millis(200), registered.as_mut()).await;
589        assert!(
590            landed.is_ok(),
591            "a waiter that registered before the notification was sent sees it"
592        );
593        Ok(())
594    }
595
596    /// THE RACE, RUN: a cancel landing while the waiter is on its way into the
597    /// wait must never be lost. Bounded — a fixed number of rounds, each with a
598    /// patience wide enough that a healthy round never approaches it — and it
599    /// FAILS rather than hangs, naming the round that lost its cancellation.
600    ///
601    /// The defect this guards was reproducible at roughly one round in tens of
602    /// thousands, so a green run of this test is a guard and not a proof; the
603    /// proof is the ordering test above and the `enable`-before-read structure
604    /// it pins. What this catches is a future change that quietly reintroduces
605    /// the read-then-register order.
606    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
607    async fn a_cancel_racing_the_waiter_into_the_wait_is_never_lost() -> TestResult {
608        const ROUNDS: usize = 20_000;
609        const PATIENCE: Duration = Duration::from_secs(30);
610
611        for round in 0..ROUNDS {
612            let (context, handle) = ActivityContext::new(
613                aion_core::WorkflowId::new_v4(),
614                aion_core::RunId::new_v4(),
615                ActivityId::from_sequence_position(1),
616                1,
617            );
618            let waiter = tokio::spawn(async move { context.cancelled().await });
619            // The canceller runs on a BLOCKING pool thread, which is a real
620            // thread of its own rather than a task queued behind the waiter:
621            // the two sides have to be genuinely parallel for the window to be
622            // reachable at all, and a canceller that can only run after the
623            // waiter has parked would make every round pass vacuously.
624            let canceller = tokio::task::spawn_blocking(move || handle.cancel());
625            match tokio::time::timeout(PATIENCE, waiter).await {
626                Ok(joined) => joined?,
627                Err(elapsed) => {
628                    return Err(format!(
629                        "round {round}: the waiter never woke ({elapsed}) — a cancellation was \
630                         lost, which is the read-then-register window"
631                    )
632                    .into());
633                }
634            }
635            canceller.await?;
636        }
637        Ok(())
638    }
639
640    #[tokio::test]
641    async fn context_exposes_identity_attempt_and_cancellation_signal() {
642        let activity_id = ActivityId::from_sequence_position(42);
643        let (context, cancellation) = ActivityContext::new(
644            aion_core::WorkflowId::new_v4(),
645            aion_core::RunId::new_v4(),
646            activity_id.clone(),
647            3,
648        );
649
650        assert_eq!(context.activity_id(), &activity_id);
651        assert_eq!(context.attempt(), 3);
652        assert!(!context.is_cancelled());
653
654        cancellation.cancel();
655
656        assert!(context.is_cancelled());
657        let cancelled = tokio::time::timeout(Duration::from_millis(50), context.cancelled()).await;
658        assert!(cancelled.is_ok());
659    }
660}