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;