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