aion_server/worker/intervention.rs
1//! Server-side mid-run intervention routing (NOI-6).
2//!
3//! This is the SERVER half of the intervention path: it takes a neutral
4//! [`InterventionCommand`] an operator submitted, resolves the worker currently
5//! owning the target `(workflow, activity, attempt)` session, **gates on that
6//! worker's advertised [`InterventionCapabilities`]**, and — only for an advertised
7//! primitive — routes the command to the owning worker over a pluggable
8//! [`InterventionTransport`] (the production one is the liminal server-push, §6.2).
9//! It returns the neutral [`InterventionOutcome`] ack the operator sees.
10//!
11//! # Harness-neutral by construction
12//!
13//! Nothing here names a harness or a wire protocol. The router speaks ONLY neutral
14//! `aion-core` types; the transport trait carries a neutral command and returns a
15//! neutral ack, so the only harness-specific translation stays behind the worker's
16//! `AgentSession`, far below this module.
17//!
18//! # The three locked outcome classes (§6.4)
19//!
20//! - **Not supported** — the owning worker does not advertise the command's
21//! primitive. The router refuses it at the SERVER and NEVER sends it, returning
22//! [`InterventionOutcome::capability_not_supported`]. This is the LOCKED
23//! server-side gate: `-32601` is reserved for the degenerate protocol bug of a
24//! child rejecting a method the server should have gated, NEVER routine gating.
25//! - **Too late / wrong attempt** — no worker owns the target attempt (finished,
26//! superseded, or unknown). The router returns
27//! [`InterventionOutcome::stale_target`] — the attempt-scoped no-op, an honest
28//! NACK surfaced to the operator, never a crash.
29//! - **Applied** — the command reached the live session and was applied.
30
31use aion_core::{
32 ActivityEvent, ActivityEventKind, ActivityId, InterventionCapabilities, InterventionCommand,
33 InterventionKind, InterventionOutcome, MessageRole, WorkflowId,
34};
35use async_trait::async_trait;
36use std::collections::HashMap;
37use std::sync::{Arc, Mutex};
38
39use super::registry::{ConnectedWorkerRegistry, WorkerHandle, WorkerId};
40use crate::error::ServerError;
41
42/// The `(workflow, activity, attempt)` key one running agent session is addressed
43/// by — the same identity the worker back-index and the whole design key on.
44#[derive(Clone, Debug, Eq, Hash, PartialEq)]
45pub struct AttemptKey {
46 /// The workflow the target activity belongs to.
47 pub workflow_id: WorkflowId,
48 /// The target activity within the workflow.
49 pub activity_id: ActivityId,
50 /// The target attempt. A command to any other attempt is a stale-target no-op.
51 pub attempt: u32,
52}
53
54impl AttemptKey {
55 /// Build an attempt key from its three components.
56 #[must_use]
57 pub const fn new(workflow_id: WorkflowId, activity_id: ActivityId, attempt: u32) -> Self {
58 Self {
59 workflow_id,
60 activity_id,
61 attempt,
62 }
63 }
64
65 /// The attempt key naming the target of a routed command.
66 #[must_use]
67 pub fn of_command(command: &InterventionCommand) -> Self {
68 Self::new(
69 command.workflow_id.clone(),
70 command.activity_id.clone(),
71 command.attempt,
72 )
73 }
74}
75
76/// Server-side `attempt -> owning-worker` back-index (§6.2).
77///
78/// The router resolves the CURRENT owner of a target attempt here. An entry is
79/// installed when an agent attempt is dispatched to a worker and removed when it
80/// completes / fails over, so the index reflects who owns each live attempt right
81/// now — NOT a stale registry snapshot. A miss is the attempt-scoped no-op.
82#[derive(Clone, Debug, Default)]
83pub struct AttemptOwnerIndex {
84 inner: Arc<Mutex<HashMap<AttemptKey, WorkerId>>>,
85}
86
87impl AttemptOwnerIndex {
88 /// Build an empty owner index.
89 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 /// Record that `worker` currently owns the session for `key`.
95 ///
96 /// Called when an agent attempt is dispatched. A later attempt overwrites the
97 /// prior entry for the same `(workflow, activity)` at a new attempt number —
98 /// each attempt is its own key, so a superseded attempt simply has no entry.
99 pub fn bind(&self, key: AttemptKey, worker: WorkerId) {
100 if let Ok(mut index) = self.inner.lock() {
101 index.insert(key, worker);
102 }
103 }
104
105 /// Remove the owner entry for `key` (the attempt finished or migrated).
106 pub fn release(&self, key: &AttemptKey) {
107 if let Ok(mut index) = self.inner.lock() {
108 index.remove(key);
109 }
110 }
111
112 /// The worker currently owning the session for `key`, if any.
113 #[must_use]
114 pub fn owner(&self, key: &AttemptKey) -> Option<WorkerId> {
115 self.inner.lock().ok()?.get(key).copied()
116 }
117
118 /// Every live attempt owned for `workflow_id`, paired with its owning worker.
119 ///
120 /// The console reads this to enumerate the attempts an operator can currently
121 /// intervene on within one workflow — only attempts with a LIVE owner appear,
122 /// so a finished or superseded attempt (which has no entry) is never offered.
123 /// A poisoned lock yields an empty enumeration, never a panic.
124 #[must_use]
125 pub fn attempts_for_workflow(&self, workflow_id: &WorkflowId) -> Vec<(AttemptKey, WorkerId)> {
126 let Ok(index) = self.inner.lock() else {
127 return Vec::new();
128 };
129 index
130 .iter()
131 .filter(|(key, _worker)| &key.workflow_id == workflow_id)
132 .map(|(key, worker)| (key.clone(), *worker))
133 .collect()
134 }
135}
136
137/// The transport the router pushes a gated command to the owning worker over.
138///
139/// The production implementation is the liminal server-push
140/// ([`LiminalInterventionTransport`], §6.2); a test/in-proc implementation delivers
141/// straight to a worker's control back-index. The trait is neutral: a command in,
142/// a neutral ack out.
143#[async_trait]
144pub trait InterventionTransport: Send + Sync {
145 /// Push one command to the worker addressed by `worker` and return its ack.
146 ///
147 /// # Errors
148 ///
149 /// Returns [`ServerError`] only for a genuine transport fault (the connection
150 /// was lost before the command could be delivered). A capability-gated or
151 /// stale-target *outcome* is a normal [`InterventionOutcome`], not an error.
152 async fn push(
153 &self,
154 worker: &WorkerHandle,
155 command: InterventionCommand,
156 ) -> Result<InterventionOutcome, ServerError>;
157}
158
159/// Routes an operator's neutral command to the worker owning the target attempt,
160/// gating on the worker's advertised capabilities first (NOI-6).
161pub struct InterventionRouter {
162 registry: ConnectedWorkerRegistry,
163 owners: AttemptOwnerIndex,
164 transport: Arc<dyn InterventionTransport>,
165 /// When installed, an APPLIED `InjectMessage` is teed into the durable
166 /// transcript as an operator `User` message (lane #229): the retained
167 /// record then holds what the operator actually said, not only the
168 /// harness's own output. `None` keeps the pre-retention behaviour.
169 transcript: Option<crate::activity_publisher::ActivityEventPublisher>,
170}
171
172impl std::fmt::Debug for InterventionRouter {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("InterventionRouter").finish_non_exhaustive()
175 }
176}
177
178impl InterventionRouter {
179 /// Build a router over the connected-worker `registry`, the attempt-owner
180 /// `owners` index, and the command `transport`.
181 #[must_use]
182 pub fn new(
183 registry: ConnectedWorkerRegistry,
184 owners: AttemptOwnerIndex,
185 transport: Arc<dyn InterventionTransport>,
186 ) -> Self {
187 Self {
188 registry,
189 owners,
190 transport,
191 transcript: None,
192 }
193 }
194
195 /// Install the transcript publisher an applied `InjectMessage` is teed
196 /// into (as an operator `User` message on the target attempt's durable
197 /// stream). Retention here is best-effort at this seam: a publish failure
198 /// is logged and the `Applied` ack still returned, exactly like the
199 /// worker-ingress tap.
200 #[must_use]
201 pub fn with_transcript_publisher(
202 mut self,
203 publisher: crate::activity_publisher::ActivityEventPublisher,
204 ) -> Self {
205 self.transcript = Some(publisher);
206 self
207 }
208
209 /// The attempt-owner index the router resolves through, so the dispatch path
210 /// can bind/release ownership on the same index.
211 #[must_use]
212 pub fn owners(&self) -> &AttemptOwnerIndex {
213 &self.owners
214 }
215
216 /// Route one operator command to the owning worker, returning the neutral ack.
217 ///
218 /// Resolves the owning worker for the command's `(workflow, activity, attempt)`.
219 /// A missing owner or a registry entry that has since disconnected is the
220 /// attempt-scoped no-op ([`InterventionOutcome::stale_target`]). When the owning
221 /// worker does not advertise the command's primitive, the router refuses it at
222 /// the server ([`InterventionOutcome::capability_not_supported`]) and NEVER
223 /// sends it. Otherwise it pushes over the transport and returns the worker's
224 /// ack. A transport fault (the connection dropped mid-route) is mapped to a
225 /// stale-target no-op — the target is unreachable, which is exactly the
226 /// too-late class from the operator's view.
227 ///
228 /// # Errors
229 ///
230 /// Returns [`ServerError::LockPoisoned`] only if the registry lock is poisoned.
231 pub async fn route(
232 &self,
233 command: InterventionCommand,
234 ) -> Result<InterventionOutcome, ServerError> {
235 let key = AttemptKey::of_command(&command);
236 let primitive = command.kind.primitive();
237
238 let Some(worker_id) = self.owners.owner(&key) else {
239 return Ok(stale(&key));
240 };
241 let Some(worker) = self.registry.worker_by_id(worker_id)? else {
242 // The owner disconnected between binding and routing: too-late no-op.
243 return Ok(stale(&key));
244 };
245
246 // The LOCKED server-side capability gate: refuse an unadvertised primitive
247 // HERE and never emit it to the worker.
248 if !worker.intervention_capabilities().supports(&command.kind) {
249 return Ok(InterventionOutcome::capability_not_supported(primitive));
250 }
251
252 // Capture the injected text BEFORE the command is moved into the
253 // transport, so an APPLIED inject can be teed into the durable
254 // transcript (lane #229) — the retained record must hold what the
255 // operator said, on every transport.
256 let injected = match &command.kind {
257 InterventionKind::InjectMessage { text, .. } => Some((text.clone(), command.issued_at)),
258 _ => None,
259 };
260
261 match self.transport.push(&worker, command).await {
262 Ok(outcome) => {
263 if let (true, Some((text, issued_at))) = (outcome.is_applied(), injected) {
264 self.retain_injected_message(&key, text, issued_at).await;
265 }
266 Ok(outcome)
267 }
268 // A dropped connection means the owning attempt is unreachable — from
269 // the operator's view that is the too-late / gone class, an honest NACK.
270 Err(error) if error.is_worker_connection_lost() => {
271 Ok(InterventionOutcome::stale_target(format!(
272 "owning worker connection lost before the command was applied: {error}"
273 )))
274 }
275 Err(error) => Err(error),
276 }
277 }
278
279 /// Tee one APPLIED `InjectMessage` into the target attempt's durable
280 /// transcript as an operator `User` message.
281 ///
282 /// `agent_id` is nil — the server-origin operator record. Nil never
283 /// collides with a real agent's delta-stream coalescing in the console
284 /// (which joins deltas on `agent_id`), so the record renders as its own
285 /// turn. Retention is best-effort at this seam: the intervention DID
286 /// apply, so a publish failure is logged and the `Applied` ack still
287 /// returned — the same doctrine as the worker-ingress observability tap.
288 async fn retain_injected_message(
289 &self,
290 key: &AttemptKey,
291 text: String,
292 issued_at: chrono::DateTime<chrono::Utc>,
293 ) {
294 let Some(publisher) = &self.transcript else {
295 return;
296 };
297 let event = ActivityEvent {
298 workflow_id: key.workflow_id.clone(),
299 activity_id: key.activity_id.clone(),
300 attempt: key.attempt,
301 agent_id: uuid::Uuid::nil(),
302 agent_role: "operator".to_owned(),
303 emitted_at: issued_at,
304 worker_seq: 0,
305 store_seq: None,
306 ephemeral: false,
307 kind: ActivityEventKind::Message {
308 role: MessageRole::User,
309 text,
310 },
311 };
312 if let Err(error) = publisher.publish(&event).await {
313 tracing::warn!(
314 %error,
315 workflow_id = %key.workflow_id,
316 activity_id = %key.activity_id,
317 attempt = key.attempt,
318 "applied InjectMessage could not be retained in the transcript"
319 );
320 }
321 }
322
323 /// The advertised capability set of the worker currently owning `key`, if any —
324 /// what the ops console reads to decide which controls to offer (NOI-7).
325 ///
326 /// # Errors
327 ///
328 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
329 pub fn capabilities_for(
330 &self,
331 key: &AttemptKey,
332 ) -> Result<Option<InterventionCapabilities>, ServerError> {
333 let Some(worker_id) = self.owners.owner(key) else {
334 return Ok(None);
335 };
336 Ok(self
337 .registry
338 .worker_by_id(worker_id)?
339 .map(|worker| worker.intervention_capabilities().clone()))
340 }
341
342 /// Every live, intervenable attempt of `workflow_id` paired with its owning
343 /// worker's advertised [`InterventionCapabilities`] — the enumeration the ops
344 /// console reads to pick a target and gate controls (NOI-7).
345 ///
346 /// Only attempts with a LIVE owner appear: a finished or superseded attempt has
347 /// no owner entry and is not enumerated. An attempt whose owner has since
348 /// disconnected (present in the index but gone from the registry) is likewise
349 /// dropped, so the console never offers a control for an unreachable attempt.
350 /// The capability set is the SAME advertised set the router gates `route` on, so
351 /// the console and the server agree on exactly which primitives are supported.
352 ///
353 /// # Errors
354 ///
355 /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
356 pub fn intervenable_attempts(
357 &self,
358 workflow_id: &WorkflowId,
359 ) -> Result<Vec<(AttemptKey, InterventionCapabilities)>, ServerError> {
360 let mut attempts = Vec::new();
361 for (key, worker_id) in self.owners.attempts_for_workflow(workflow_id) {
362 if let Some(worker) = self.registry.worker_by_id(worker_id)? {
363 attempts.push((key, worker.intervention_capabilities().clone()));
364 }
365 }
366 Ok(attempts)
367 }
368}
369
370/// The stale-target no-op ack for a target with no live owner.
371fn stale(key: &AttemptKey) -> InterventionOutcome {
372 InterventionOutcome::stale_target(format!(
373 "no live owner for attempt {} of activity {} in workflow {}",
374 key.attempt, key.activity_id, key.workflow_id
375 ))
376}
377
378#[cfg(test)]
379#[path = "intervention_tests.rs"]
380mod tests;