Skip to main content

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;