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