Skip to main content

aion_worker/runtime/
intervention.rs

1//! Worker-side mid-run intervention delivery (NOI-6).
2//!
3//! This is the WORKER half of the intervention routing path: a command routed
4//! operator -> server -> liminal PUSH arrives here and is delivered to the
5//! in-flight agent session that owns the target `(workflow, activity, attempt)`,
6//! which applies it via [`AgentSession::intervene`](aion_integrations::contract::AgentSession::intervene)
7//! and returns a neutral ack.
8//!
9//! # Harness-neutral by construction
10//!
11//! Nothing in this module names a harness, a wire protocol, or a concrete adapter.
12//! It speaks ONLY the neutral `aion-core` control vocabulary: an
13//! [`InterventionCommand`] in, an [`InterventionOutcome`] ack out. The command is
14//! delivered onto the session's driver control channel (a [`ControlMessage`]),
15//! where [`spawn_agent`](super::agent::spawn_agent) drains it into the session and
16//! replies the ack — so the harness-specific translation stays behind the
17//! `AgentSession` trait, never here.
18//!
19//! # The attempt back-index (§6.4)
20//!
21//! [`ControlRegistry`] is the worker's `(workflow, activity, attempt) -> session
22//! control channel` back-index. A command whose target has no live entry — because
23//! the attempt finished, was superseded by a later attempt, or never ran here — is
24//! the attempt-scoped no-op: it returns [`InterventionOutcome::stale_target`], an
25//! honest NACK, never a panic. A command whose primitive the target session does
26//! not advertise returns [`InterventionOutcome::capability_not_supported`]. Neither
27//! is an error: they are two of the three locked outcome classes.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32use aion_core::{
33    ActivityId, InterventionCapabilities, InterventionCommand, InterventionOutcome, RunId,
34    WorkflowId,
35};
36use tokio::sync::{mpsc, oneshot};
37
38use super::agent::ControlMessage;
39
40/// The `(workflow, run, activity, attempt)` key one running agent session is
41/// addressed by — the exact identity the design keys the whole intervention path
42/// on, and the mirror of the server-side `AttemptKey`.
43///
44/// The run is part of the key for the same reason it is part of the transcript
45/// stream key: a continue-as-new chain reuses one workflow id while ordinals and
46/// attempts restart per generation, so a run-blind key would let a command aimed
47/// at generation two reach generation one's live session. Delivering a steer to
48/// the wrong generation is a control-plane fault, not a cosmetic one.
49#[derive(Clone, Debug, Eq, Hash, PartialEq)]
50pub struct SessionKey {
51    /// The workflow the target activity belongs to.
52    pub workflow_id: WorkflowId,
53    /// The concrete run of that workflow the target attempt belongs to.
54    pub run_id: RunId,
55    /// The target activity within the workflow.
56    pub activity_id: ActivityId,
57    /// The target attempt. A command to any other attempt is a stale-target no-op.
58    pub attempt: u32,
59}
60
61impl SessionKey {
62    /// Build a session key from its four components.
63    #[must_use]
64    pub const fn new(
65        workflow_id: WorkflowId,
66        run_id: RunId,
67        activity_id: ActivityId,
68        attempt: u32,
69    ) -> Self {
70        Self {
71            workflow_id,
72            run_id,
73            activity_id,
74            attempt,
75        }
76    }
77
78    /// The session key naming the target of a routed command.
79    #[must_use]
80    pub fn of_command(command: &InterventionCommand) -> Self {
81        Self::new(
82            command.workflow_id.clone(),
83            command.run_id.clone(),
84            command.activity_id.clone(),
85            command.attempt,
86        )
87    }
88}
89
90/// One live session's control leg: the driver control-channel sender the command
91/// is delivered onto, plus the neutral capability set the worker gates on.
92#[derive(Clone, Debug)]
93struct SessionControl {
94    control: mpsc::UnboundedSender<ControlMessage>,
95    capabilities: InterventionCapabilities,
96}
97
98/// The worker's attempt back-index: maps a live [`SessionKey`] to its session
99/// control leg, so a routed command reaches the exact in-flight attempt.
100///
101/// A session installs itself with [`Self::register`] when it starts and the
102/// returned [`SessionGuard`] removes it on drop, so the index tracks exactly the
103/// sessions running on this worker. It is cheap to clone (an `Arc` inside), so the
104/// liminal serve loop and the session-spawn path share one index.
105#[derive(Clone, Debug, Default)]
106pub struct ControlRegistry {
107    inner: Arc<Mutex<HashMap<SessionKey, SessionControl>>>,
108}
109
110impl ControlRegistry {
111    /// Build an empty control registry.
112    #[must_use]
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Register a live session's control leg under its `key`, returning a guard
118    /// whose drop deregisters it (so the index never routes to a gone session).
119    ///
120    /// The `control` sender is the driver's control channel (the receiver half is
121    /// handed to [`spawn_agent`](super::agent::spawn_agent) as its
122    /// `control_receiver`); `capabilities` is the session's advertised neutral
123    /// primitive set, which the worker gates on before delivering a command.
124    #[must_use]
125    pub fn register(
126        &self,
127        key: SessionKey,
128        control: mpsc::UnboundedSender<ControlMessage>,
129        capabilities: InterventionCapabilities,
130    ) -> SessionGuard {
131        if let Ok(mut index) = self.inner.lock() {
132            index.insert(
133                key.clone(),
134                SessionControl {
135                    control,
136                    capabilities,
137                },
138            );
139        }
140        SessionGuard {
141            registry: self.clone(),
142            key,
143        }
144    }
145
146    /// Deliver one routed command to the session that owns its target, returning
147    /// the neutral [`InterventionOutcome`] ack.
148    ///
149    /// Resolves the target session by `(workflow, activity, attempt)`. When no live
150    /// session owns the target, returns [`InterventionOutcome::stale_target`] — the
151    /// attempt-scoped no-op. When the session's advertised capabilities do not
152    /// include the command's primitive, returns
153    /// [`InterventionOutcome::capability_not_supported`] WITHOUT delivering it (the
154    /// worker gate mirrors the server gate; a well-behaved server never sends an
155    /// unadvertised primitive, but the worker refuses one cleanly if it arrives).
156    /// Otherwise it delivers the command and awaits the driver's ack.
157    pub async fn deliver(&self, command: InterventionCommand) -> InterventionOutcome {
158        let key = SessionKey::of_command(&command);
159        let primitive = command.kind.primitive();
160        let Some(session) = self.lookup(&key) else {
161            return InterventionOutcome::stale_target(format!(
162                "no live session for attempt {} of activity {} in workflow {}",
163                key.attempt, key.activity_id, key.workflow_id
164            ));
165        };
166        if !session.capabilities.supports(&command.kind) {
167            return InterventionOutcome::capability_not_supported(primitive);
168        }
169        let (ack_tx, ack_rx) = oneshot::channel();
170        if session
171            .control
172            .send(ControlMessage::with_ack(command, ack_tx))
173            .is_err()
174        {
175            // The driver's control receiver is gone — the session ended between the
176            // lookup and the send. This is the stale-target no-op, not a fault.
177            return InterventionOutcome::stale_target(format!(
178                "session for attempt {} ended before the command was applied",
179                key.attempt
180            ));
181        }
182        match ack_rx.await {
183            Ok(outcome) => outcome,
184            // The driver dropped the ack sender without replying (session ended
185            // mid-apply): honest stale-target NACK, never a crash.
186            Err(_) => InterventionOutcome::stale_target(format!(
187                "session for attempt {} ended before acking the command",
188                key.attempt
189            )),
190        }
191    }
192
193    /// Snapshot a session's control leg for `key`, if one is registered.
194    fn lookup(&self, key: &SessionKey) -> Option<SessionControl> {
195        self.inner.lock().ok()?.get(key).cloned()
196    }
197
198    fn remove(&self, key: &SessionKey) {
199        if let Ok(mut index) = self.inner.lock() {
200            index.remove(key);
201        }
202    }
203}
204
205/// Drop guard removing a session's control leg from the [`ControlRegistry`] when
206/// the session ends, so the back-index tracks exactly the live sessions.
207#[derive(Debug)]
208pub struct SessionGuard {
209    registry: ControlRegistry,
210    key: SessionKey,
211}
212
213impl Drop for SessionGuard {
214    fn drop(&mut self) {
215        self.registry.remove(&self.key);
216    }
217}
218
219#[cfg(test)]
220#[path = "intervention_tests.rs"]
221mod tests;