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, WorkflowId,
34};
35use tokio::sync::{mpsc, oneshot};
36
37use super::agent::ControlMessage;
38
39/// The `(workflow, activity, attempt)` key one running agent session is addressed
40/// by — the exact identity the design keys the whole intervention path on.
41#[derive(Clone, Debug, Eq, Hash, PartialEq)]
42pub struct SessionKey {
43    /// The workflow the target activity belongs to.
44    pub workflow_id: WorkflowId,
45    /// The target activity within the workflow.
46    pub activity_id: ActivityId,
47    /// The target attempt. A command to any other attempt is a stale-target no-op.
48    pub attempt: u32,
49}
50
51impl SessionKey {
52    /// Build a session key from its three components.
53    #[must_use]
54    pub const fn new(workflow_id: WorkflowId, activity_id: ActivityId, attempt: u32) -> Self {
55        Self {
56            workflow_id,
57            activity_id,
58            attempt,
59        }
60    }
61
62    /// The session key naming the target of a routed command.
63    #[must_use]
64    pub fn of_command(command: &InterventionCommand) -> Self {
65        Self::new(
66            command.workflow_id.clone(),
67            command.activity_id.clone(),
68            command.attempt,
69        )
70    }
71}
72
73/// One live session's control leg: the driver control-channel sender the command
74/// is delivered onto, plus the neutral capability set the worker gates on.
75#[derive(Clone, Debug)]
76struct SessionControl {
77    control: mpsc::UnboundedSender<ControlMessage>,
78    capabilities: InterventionCapabilities,
79}
80
81/// The worker's attempt back-index: maps a live [`SessionKey`] to its session
82/// control leg, so a routed command reaches the exact in-flight attempt.
83///
84/// A session installs itself with [`Self::register`] when it starts and the
85/// returned [`SessionGuard`] removes it on drop, so the index tracks exactly the
86/// sessions running on this worker. It is cheap to clone (an `Arc` inside), so the
87/// liminal serve loop and the session-spawn path share one index.
88#[derive(Clone, Debug, Default)]
89pub struct ControlRegistry {
90    inner: Arc<Mutex<HashMap<SessionKey, SessionControl>>>,
91}
92
93impl ControlRegistry {
94    /// Build an empty control registry.
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Register a live session's control leg under its `key`, returning a guard
101    /// whose drop deregisters it (so the index never routes to a gone session).
102    ///
103    /// The `control` sender is the driver's control channel (the receiver half is
104    /// handed to [`spawn_agent`](super::agent::spawn_agent) as its
105    /// `control_receiver`); `capabilities` is the session's advertised neutral
106    /// primitive set, which the worker gates on before delivering a command.
107    #[must_use]
108    pub fn register(
109        &self,
110        key: SessionKey,
111        control: mpsc::UnboundedSender<ControlMessage>,
112        capabilities: InterventionCapabilities,
113    ) -> SessionGuard {
114        if let Ok(mut index) = self.inner.lock() {
115            index.insert(
116                key.clone(),
117                SessionControl {
118                    control,
119                    capabilities,
120                },
121            );
122        }
123        SessionGuard {
124            registry: self.clone(),
125            key,
126        }
127    }
128
129    /// Deliver one routed command to the session that owns its target, returning
130    /// the neutral [`InterventionOutcome`] ack.
131    ///
132    /// Resolves the target session by `(workflow, activity, attempt)`. When no live
133    /// session owns the target, returns [`InterventionOutcome::stale_target`] — the
134    /// attempt-scoped no-op. When the session's advertised capabilities do not
135    /// include the command's primitive, returns
136    /// [`InterventionOutcome::capability_not_supported`] WITHOUT delivering it (the
137    /// worker gate mirrors the server gate; a well-behaved server never sends an
138    /// unadvertised primitive, but the worker refuses one cleanly if it arrives).
139    /// Otherwise it delivers the command and awaits the driver's ack.
140    pub async fn deliver(&self, command: InterventionCommand) -> InterventionOutcome {
141        let key = SessionKey::of_command(&command);
142        let primitive = command.kind.primitive();
143        let Some(session) = self.lookup(&key) else {
144            return InterventionOutcome::stale_target(format!(
145                "no live session for attempt {} of activity {} in workflow {}",
146                key.attempt, key.activity_id, key.workflow_id
147            ));
148        };
149        if !session.capabilities.supports(&command.kind) {
150            return InterventionOutcome::capability_not_supported(primitive);
151        }
152        let (ack_tx, ack_rx) = oneshot::channel();
153        if session
154            .control
155            .send(ControlMessage::with_ack(command, ack_tx))
156            .is_err()
157        {
158            // The driver's control receiver is gone — the session ended between the
159            // lookup and the send. This is the stale-target no-op, not a fault.
160            return InterventionOutcome::stale_target(format!(
161                "session for attempt {} ended before the command was applied",
162                key.attempt
163            ));
164        }
165        match ack_rx.await {
166            Ok(outcome) => outcome,
167            // The driver dropped the ack sender without replying (session ended
168            // mid-apply): honest stale-target NACK, never a crash.
169            Err(_) => InterventionOutcome::stale_target(format!(
170                "session for attempt {} ended before acking the command",
171                key.attempt
172            )),
173        }
174    }
175
176    /// Snapshot a session's control leg for `key`, if one is registered.
177    fn lookup(&self, key: &SessionKey) -> Option<SessionControl> {
178        self.inner.lock().ok()?.get(key).cloned()
179    }
180
181    fn remove(&self, key: &SessionKey) {
182        if let Ok(mut index) = self.inner.lock() {
183            index.remove(key);
184        }
185    }
186}
187
188/// Drop guard removing a session's control leg from the [`ControlRegistry`] when
189/// the session ends, so the back-index tracks exactly the live sessions.
190#[derive(Debug)]
191pub struct SessionGuard {
192    registry: ControlRegistry,
193    key: SessionKey,
194}
195
196impl Drop for SessionGuard {
197    fn drop(&mut self) {
198        self.registry.remove(&self.key);
199    }
200}
201
202#[cfg(test)]
203#[path = "intervention_tests.rs"]
204mod tests;