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;