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, 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    workflow_id: Option<WorkflowId>,
15    activity_id: ActivityId,
16    attempt: u32,
17    idempotency_key: Option<String>,
18    cancellation: Arc<CancellationState>,
19    heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
20    /// NOI-5b agent-observability event seam (additive, OPTIONAL). A running
21    /// activity (or the harness adapter driving it) emits neutral
22    /// [`ActivityEvent`]s here; the worker runtime drains them and forwards them
23    /// to the server's transcript sequencer over the same transport activity
24    /// results take. A context created WITHOUT this seam — every isolated unit
25    /// test and every activity that emits nothing — is a no-op, byte-identical to
26    /// today, exactly as the `heartbeat_sender` seam is.
27    event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
28}
29
30/// Internal handle used by the worker runtime to signal cooperative cancellation.
31#[derive(Clone, Debug)]
32pub struct ActivityCancellationHandle {
33    cancellation: Arc<CancellationState>,
34}
35
36/// Heartbeat request emitted by [`ActivityContext::heartbeat`].
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct HeartbeatRequest {
39    /// Workflow owning the activity whose progress is being reported.
40    pub workflow_id: WorkflowId,
41    /// Activity whose progress is being reported.
42    pub activity_id: ActivityId,
43    /// Opaque progress detail supplied by the handler.
44    pub detail: Option<Payload>,
45}
46
47#[derive(Debug)]
48struct CancellationState {
49    cancelled: AtomicBool,
50    notify: Notify,
51}
52
53impl ActivityContext {
54    /// Creates a context and the internal handle that can signal cancellation.
55    #[must_use]
56    pub fn new(activity_id: ActivityId, attempt: u32) -> (Self, ActivityCancellationHandle) {
57        Self::with_heartbeat_sender(activity_id, attempt, None)
58    }
59
60    /// Returns this activity's identifier.
61    #[must_use]
62    pub const fn activity_id(&self) -> &ActivityId {
63        &self.activity_id
64    }
65
66    /// Returns this activity's attempt number.
67    #[must_use]
68    pub const fn attempt(&self) -> u32 {
69        self.attempt
70    }
71
72    /// Returns the stable external-effect key delivered with this task.
73    ///
74    /// Live worker tasks always return `Some`; manually constructed unit-test
75    /// contexts return `None` because they are not attached to a server task.
76    #[must_use]
77    pub fn idempotency_key(&self) -> Option<&str> {
78        self.idempotency_key.as_deref()
79    }
80
81    /// Emits a cooperative heartbeat request for this activity.
82    ///
83    /// This is the PROGRESS channel: handlers call it to attach a progress
84    /// payload to the activity's liveness record. LIVENESS itself is owned by
85    /// the worker runtime, which automatically heartbeats every in-flight
86    /// activity within the server-assigned heartbeat window — a handler that
87    /// never calls this still stays live for as long as it genuinely runs.
88    /// Contexts created without a live heartbeat sender remain no-op contexts
89    /// for isolated unit tests.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`WorkerError`] when an installed heartbeat seam has been closed
94    /// or when the context lacks the workflow id required by the live session.
95    pub fn heartbeat(&self, detail: Option<Payload>) -> Result<(), WorkerError> {
96        if let Some(sender) = &self.heartbeat_sender {
97            let workflow_id = self.workflow_id.clone().ok_or_else(|| {
98                WorkerError::registration(HeartbeatMissingWorkflow {
99                    activity_id: self.activity_id.clone(),
100                })
101            })?;
102            sender
103                .send(HeartbeatRequest {
104                    workflow_id,
105                    activity_id: self.activity_id.clone(),
106                    detail,
107                })
108                .map_err(|source| WorkerError::registration(HeartbeatSeamClosed { source }))?;
109        }
110        Ok(())
111    }
112
113    /// Emit a neutral agent-observability [`ActivityEvent`] onto the transcript
114    /// seam (NOI-5b).
115    ///
116    /// Additive and OPTIONAL: on a context created without a live event seam
117    /// (every isolated unit test, and every activity that does not run an
118    /// instrumented agent) this is a no-op returning `Ok(())`, so behaviour is
119    /// byte-identical to today. When a seam is installed the worker runtime drains
120    /// these events and forwards them to the server's transcript sequencer, which
121    /// stamps the commit-allocated `store_seq` — the producer never assigns it.
122    ///
123    /// Harness-neutral: the payload is a pure `aion-core` [`ActivityEvent`]; the
124    /// per-harness mapping lives in the worker-side adapter, never here.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`WorkerError`] when an installed event seam has been closed (the
129    /// runtime drain end was dropped) — a dropped transcript event is surfaced,
130    /// never silently swallowed.
131    pub fn emit_event(&self, event: ActivityEvent) -> Result<(), WorkerError> {
132        if let Some(sender) = &self.event_sender {
133            sender
134                .send(event)
135                .map_err(|source| WorkerError::registration(EventSeamClosed { source }))?;
136        }
137        Ok(())
138    }
139
140    /// Returns true once cooperative cancellation has been signalled.
141    #[must_use]
142    pub fn is_cancelled(&self) -> bool {
143        self.cancellation.cancelled.load(Ordering::Acquire)
144    }
145
146    /// Resolves when cooperative cancellation is signalled.
147    pub async fn cancelled(&self) {
148        while !self.is_cancelled() {
149            self.cancellation.notify.notified().await;
150        }
151    }
152
153    pub(crate) fn with_heartbeat_sender(
154        activity_id: ActivityId,
155        attempt: u32,
156        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
157    ) -> (Self, ActivityCancellationHandle) {
158        Self::for_workflow(None, activity_id, attempt, heartbeat_sender)
159    }
160
161    pub(crate) fn for_workflow(
162        workflow_id: Option<WorkflowId>,
163        activity_id: ActivityId,
164        attempt: u32,
165        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
166    ) -> (Self, ActivityCancellationHandle) {
167        Self::for_workflow_with_events(
168            workflow_id,
169            activity_id,
170            attempt,
171            None,
172            heartbeat_sender,
173            None,
174        )
175    }
176
177    pub(crate) fn for_task(
178        workflow_id: WorkflowId,
179        activity_id: ActivityId,
180        attempt: u32,
181        idempotency_key: String,
182        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
183    ) -> (Self, ActivityCancellationHandle) {
184        Self::for_workflow_with_events(
185            Some(workflow_id),
186            activity_id,
187            attempt,
188            Some(idempotency_key),
189            heartbeat_sender,
190            None,
191        )
192    }
193
194    /// Build a context with BOTH the heartbeat seam and the NOI-5b transcript
195    /// event seam installed. The runtime uses this when an activity is driven with
196    /// observability enabled; the pre-existing constructors default the event seam
197    /// to `None` so every current call site is unchanged.
198    pub(crate) fn for_workflow_with_events(
199        workflow_id: Option<WorkflowId>,
200        activity_id: ActivityId,
201        attempt: u32,
202        idempotency_key: Option<String>,
203        heartbeat_sender: Option<mpsc::UnboundedSender<HeartbeatRequest>>,
204        event_sender: Option<mpsc::UnboundedSender<ActivityEvent>>,
205    ) -> (Self, ActivityCancellationHandle) {
206        let cancellation = Arc::new(CancellationState {
207            cancelled: AtomicBool::new(false),
208            notify: Notify::new(),
209        });
210        let context = Self {
211            workflow_id,
212            activity_id,
213            attempt,
214            idempotency_key,
215            cancellation: Arc::clone(&cancellation),
216            heartbeat_sender,
217            event_sender,
218        };
219        let handle = ActivityCancellationHandle { cancellation };
220        (context, handle)
221    }
222}
223
224impl ActivityCancellationHandle {
225    /// Signals cooperative cancellation to the handler-facing context.
226    pub fn cancel(&self) {
227        let was_cancelled = self.cancellation.cancelled.swap(true, Ordering::AcqRel);
228        if !was_cancelled {
229            self.cancellation.notify.notify_waiters();
230        }
231    }
232}
233
234#[derive(Debug, thiserror::Error)]
235#[error("activity heartbeat seam is closed: {source}")]
236struct HeartbeatSeamClosed {
237    source: mpsc::error::SendError<HeartbeatRequest>,
238}
239
240#[derive(Debug, thiserror::Error)]
241#[error("activity transcript event seam is closed: {source}")]
242struct EventSeamClosed {
243    source: mpsc::error::SendError<ActivityEvent>,
244}
245
246#[derive(Debug, thiserror::Error)]
247#[error("activity {activity_id} heartbeat is missing workflow id")]
248struct HeartbeatMissingWorkflow {
249    activity_id: ActivityId,
250}
251
252#[cfg(test)]
253mod tests {
254    use std::time::Duration;
255
256    use aion_core::ActivityId;
257
258    use super::ActivityContext;
259
260    /// NOI-5b: a context WITHOUT an event seam is a no-op — `emit_event` returns
261    /// `Ok(())` and drops the event, byte-identical to a context that predates the
262    /// seam. This is the additive guarantee: an activity that emits nothing (and
263    /// every isolated unit test) is unaffected.
264    #[tokio::test]
265    async fn emit_event_is_a_no_op_without_an_installed_seam() {
266        use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, WorkflowId};
267        use chrono::Utc;
268        use uuid::Uuid;
269
270        let (context, _cancellation) =
271            ActivityContext::new(ActivityId::from_sequence_position(1), 0);
272        let event = ActivityEvent {
273            workflow_id: WorkflowId::new(Uuid::from_u128(1)),
274            activity_id: ActivityId::from_sequence_position(1),
275            attempt: 0,
276            agent_id: Uuid::from_u128(2),
277            agent_role: "orchestrator".to_owned(),
278            emitted_at: Utc::now(),
279            worker_seq: 1,
280            store_seq: None,
281            ephemeral: false,
282            kind: ActivityEventKind::Message {
283                role: MessageRole::Assistant,
284                text: "hello".to_owned(),
285            },
286        };
287        assert!(context.emit_event(event).is_ok());
288    }
289
290    /// With an event seam installed, `emit_event` forwards the neutral event to
291    /// the runtime drain end — the additive worker->server ingestion seam.
292    #[tokio::test]
293    async fn emit_event_forwards_to_installed_seam() -> Result<(), Box<dyn std::error::Error>> {
294        use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, WorkflowId};
295        use chrono::Utc;
296        use uuid::Uuid;
297
298        let (sender, mut drain) = super::mpsc::unbounded_channel();
299        let (context, _cancellation) = ActivityContext::for_workflow_with_events(
300            Some(WorkflowId::new(Uuid::from_u128(1))),
301            ActivityId::from_sequence_position(1),
302            0,
303            None,
304            None,
305            Some(sender),
306        );
307        let event = ActivityEvent {
308            workflow_id: WorkflowId::new(Uuid::from_u128(1)),
309            activity_id: ActivityId::from_sequence_position(1),
310            attempt: 0,
311            agent_id: Uuid::from_u128(2),
312            agent_role: "orchestrator".to_owned(),
313            emitted_at: Utc::now(),
314            worker_seq: 7,
315            store_seq: None,
316            ephemeral: false,
317            kind: ActivityEventKind::Message {
318                role: MessageRole::Assistant,
319                text: "steer".to_owned(),
320            },
321        };
322        context.emit_event(event.clone())?;
323        let delivered = drain.recv().await.ok_or("event must be delivered")?;
324        assert_eq!(delivered.worker_seq, 7);
325        assert_eq!(delivered, event);
326        Ok(())
327    }
328
329    #[test]
330    fn live_task_context_exposes_the_server_idempotency_key() {
331        let (context, cancellation) = ActivityContext::for_task(
332            aion_core::WorkflowId::new_v4(),
333            ActivityId::from_sequence_position(7),
334            3,
335            String::from("effect-key"),
336            None,
337        );
338
339        assert_eq!(context.idempotency_key(), Some("effect-key"));
340        assert_eq!(context.attempt(), 3);
341        drop(cancellation);
342    }
343
344    #[tokio::test]
345    async fn context_exposes_identity_attempt_and_cancellation_signal() {
346        let activity_id = ActivityId::from_sequence_position(42);
347        let (context, cancellation) = ActivityContext::new(activity_id.clone(), 3);
348
349        assert_eq!(context.activity_id(), &activity_id);
350        assert_eq!(context.attempt(), 3);
351        assert!(!context.is_cancelled());
352
353        cancellation.cancel();
354
355        assert!(context.is_cancelled());
356        let cancelled = tokio::time::timeout(Duration::from_millis(50), context.cancelled()).await;
357        assert!(cancelled.is_ok());
358    }
359}