Skip to main content

aion_server/worker/
declared_body_outbox.rs

1//! Declared bodies on the OUTBOX delivery path (aion#193).
2//!
3//! A plain step statement dispatches through the engine's `dispatch(ActivityDispatch)`
4//! hook, where [`DeclaredCommandDispatcher`](super::DeclaredCommandDispatcher)
5//! consults the declared-body catalog and executes a bodied action at the
6//! server. A fan-out member — every call inside `fork … join` — is written to
7//! the durable outbox and delivered by an [`OutboxRowDispatch`], which used to
8//! go straight to the connected-worker registry: nothing on that path looked at
9//! the catalog, the server's own executor is not a poller, and the strict queue
10//! gate parked the row as `NO_LIVE_POLLERS` until it dead-lettered. A one-element
11//! fork over a declared body failed identically, so this was routing, not
12//! concurrency (field case: `examples/json-between-steps/json_fanout.awl`).
13//!
14//! This decorator closes that: it consults the SAME [`DeclaredBodySource`] the
15//! direct path does, before the row is offered to any worker, and runs a
16//! declared body through the SAME [`DeclaredCommandExecutor`]. Where the two
17//! paths necessarily differ is how the result lands. The direct path returns
18//! the encoded string to the engine synchronously. An outbox row has no waiter:
19//! the engine recorded `ActivityStarted` and is waiting for a completion the
20//! way it waits for a worker's — so this path mints the attempt's completion
21//! token from the shared fences, executes off the dispatcher loop, and feeds
22//! the outcome to the [`ActivityCompletionSink`] a worker's result goes
23//! through, which delivers it into the live workflow through the fan-out
24//! completion route. The row is marked done the moment the execution is
25//! placed, exactly as it is when a worker accepts a task.
26//!
27//! What this path cannot do, stated rather than claimed away: an outbox row
28//! carries no dispatch config, so the per-attempt bound the dispatch authored
29//! (`timeout`, #223) is not known here and the body runs unbounded. Cancellation
30//! still reaches it through the attempts registry. Carrying the config on the
31//! row is the fix, and belongs to the row's contract, not to this decorator.
32//!
33//! `Unreadable` delegates to the worker path, loudly, as the direct path does —
34//! a catalog read failure must not strand a queue live workers could serve.
35//! `Ambiguous` is a terminal failure of the attempt, delivered to the workflow
36//! through the sink with the same sentence the direct path refuses with, so a
37//! fork member and a plain statement fail the same way for the same defect.
38//!
39//! The whole-server pin — a fork over declared bodies, outbox commissioned, no
40//! worker, driven through the real `run_server` boot — is
41//! `tests/outbox_declared_body_e2e.rs`; the unit tests here drive the decorator
42//! alone.
43
44use std::sync::Arc;
45
46use aion::ActivityDispatch;
47use aion_core::{ActivityError, ActivityErrorKind, ActivityId, ContentType, Payload, RunId};
48use aion_package::ActionBodyContract;
49use aion_store::OutboxRow;
50use async_trait::async_trait;
51
52use super::DeclaredCommandExecutor;
53use super::declared_body::{DeclaredBodyLookup, DeclaredBodySource, DispatchingRun};
54use super::declared_body_ambiguity::ambiguous_body_refusal;
55use super::dispatch::{ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink};
56use super::envelope::{CompletionFences, CompletionToken};
57use super::outbox_dispatcher::OutboxRowDispatch;
58use crate::error::ServerError;
59
60/// The dispatch config an outbox row is executed under: the row carries none,
61/// so the body's bound is whatever the executor reads from an empty config —
62/// none. Named so the omission is visible in one place.
63const ROW_DISPATCH_CONFIG: &str = "{}";
64
65/// [`OutboxRowDispatch`] decorator that executes declared bodies at the server
66/// and delegates every other row to the transport it wraps.
67pub struct DeclaredBodyOutboxDispatch {
68    inner: Arc<dyn OutboxRowDispatch>,
69    bodies: DeclaredBodySource,
70    executor: Arc<DeclaredCommandExecutor>,
71    fences: CompletionFences,
72    sink: Arc<dyn ActivityCompletionSink + Send + Sync>,
73    handle: tokio::runtime::Handle,
74}
75
76impl std::fmt::Debug for DeclaredBodyOutboxDispatch {
77    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        formatter
79            .debug_struct("DeclaredBodyOutboxDispatch")
80            .field("bodies", &self.bodies)
81            .finish_non_exhaustive()
82    }
83}
84
85impl DeclaredBodyOutboxDispatch {
86    /// Wrap `inner`, consulting `bodies` before every row; a declared body runs
87    /// on `executor` (off the dispatcher loop, on `handle`) and completes
88    /// through `sink` with a token minted from `fences` — the SAME fences the
89    /// worker registry issues from, so a completion here is fenced exactly like
90    /// a worker's.
91    #[must_use]
92    pub fn new(
93        inner: Arc<dyn OutboxRowDispatch>,
94        bodies: DeclaredBodySource,
95        executor: Arc<DeclaredCommandExecutor>,
96        fences: CompletionFences,
97        sink: Arc<dyn ActivityCompletionSink + Send + Sync>,
98        handle: tokio::runtime::Handle,
99    ) -> Self {
100        Self {
101            inner,
102            bodies,
103            executor,
104            fences,
105            sink,
106            handle,
107        }
108    }
109
110    /// The run a row belongs to. A row without one predates run threading and
111    /// cannot be completed against a run; it is refused back to the outbox by
112    /// name, as the worker path refuses it, rather than executed against a
113    /// guess.
114    fn run_id(row: &OutboxRow) -> Result<RunId, ServerError> {
115        row.run_id.clone().ok_or_else(|| {
116            ServerError::worker_dispatch(
117                &row.namespace,
118                &row.activity_type,
119                format!(
120                    "outbox row {} carries no run id; a declared body cannot be completed \
121                     against a run it does not name",
122                    row.dispatch_key
123                ),
124            )
125        })
126    }
127
128    /// The engine-side dispatch request a row translates to, byte-equivalent
129    /// in everything the executor reads to what the direct path hands over for
130    /// the same activity — except `config`, which the row does not carry.
131    fn request_for(row: &OutboxRow, run_id: RunId) -> Result<ActivityDispatch, String> {
132        let input = String::from_utf8(row.input.bytes().to_vec()).map_err(|_| {
133            "terminal:declared body input on the outbox row is not valid UTF-8".to_owned()
134        })?;
135        Ok(ActivityDispatch {
136            namespace: row.namespace.clone(),
137            task_queue: row.task_queue.clone(),
138            node: row.node.clone(),
139            workflow_id: row.workflow_id.clone(),
140            run_id,
141            activity_id: ActivityId::from_sequence_position(row.ordinal),
142            name: row.activity_type.clone(),
143            input,
144            config: ROW_DISPATCH_CONFIG.to_owned(),
145            attempt: row.started_attempt,
146            labels: std::collections::BTreeMap::new(),
147            advisory: false,
148        })
149    }
150
151    /// Mint the attempt's completion token: the same fences, the same
152    /// `(workflow, run, activity, attempt)` a worker delivery would be issued
153    /// for. A completion presented with it is accepted exactly as a worker's.
154    fn mint_token(&self, row: &OutboxRow, run_id: &RunId) -> Result<CompletionToken, ServerError> {
155        self.fences.issue(
156            &row.workflow_id,
157            run_id,
158            &ActivityId::from_sequence_position(row.ordinal),
159            row.started_attempt,
160        )
161    }
162
163    /// Feed one execution's end into the completion sink. `Ok` carries the
164    /// executor's encoded result as the activity's JSON payload; `Err` carries
165    /// the prefixed failure string, classified the way the direct path's
166    /// consumer classifies it.
167    fn complete(
168        sink: &(dyn ActivityCompletionSink + Send + Sync),
169        row: &OutboxRow,
170        run_id: RunId,
171        token: CompletionToken,
172        ended: Result<String, String>,
173    ) -> Result<(), ServerError> {
174        let outcome = match ended {
175            Ok(encoded) => ActivityCompletionOutcome::Succeeded(Payload::new(
176                ContentType::Json,
177                encoded.into_bytes(),
178            )),
179            Err(reason) => ActivityCompletionOutcome::Failed(classify_failure(&reason)),
180        };
181        sink.complete_activity(ActivityCompletion {
182            workflow_id: row.workflow_id.clone(),
183            activity_id: ActivityId::from_sequence_position(row.ordinal),
184            run_id: Some(run_id),
185            completion_token: token,
186            outcome,
187        })
188    }
189
190    /// Execute a declared body for `row` off the dispatcher loop and complete
191    /// it. Returns once the execution is PLACED — the row is then marked done
192    /// by the caller, as it is when a worker accepts a task — never once it
193    /// has finished.
194    fn place(&self, row: &OutboxRow, contract: ActionBodyContract) -> Result<(), ServerError> {
195        let run_id = Self::run_id(row)?;
196        let token = self.mint_token(row, &run_id)?;
197        let request = match Self::request_for(row, run_id.clone()) {
198            Ok(request) => request,
199            Err(reason) => {
200                // A row that cannot even be decoded into a request is a terminal
201                // failure of the attempt, delivered to the workflow — not a
202                // retry, which could not change the bytes.
203                return Self::complete(self.sink.as_ref(), row, run_id, token, Err(reason));
204            }
205        };
206        tracing::info!(
207            operation = "declared_command_outbox_dispatch",
208            workflow_id = %row.workflow_id,
209            activity_id = %request.activity_id,
210            activity_name = %row.activity_type,
211            task_queue = %row.task_queue,
212            attempt = row.started_attempt,
213            "executing a declared action body at the server for an outbox (fan-out) row"
214        );
215        let executor = Arc::clone(&self.executor);
216        let sink = Arc::clone(&self.sink);
217        let fences = self.fences.clone();
218        let row = row.clone();
219        self.handle.spawn(async move {
220            let contract_for_run = contract;
221            let request_for_run = request;
222            let executor_for_run = Arc::clone(&executor);
223            let ended = tokio::task::spawn_blocking(move || {
224                executor_for_run.execute(&request_for_run, &contract_for_run)
225            })
226            .await
227            .unwrap_or_else(|join_error| {
228                Err(format!(
229                    "terminal:the declared body's execution task ended abnormally: {join_error}"
230                ))
231            });
232            if ended
233                .as_ref()
234                .err()
235                .is_some_and(|reason| reason == aion::PARKED_ACTIVITY_REASON)
236            {
237                // This server is draining and started no work: nothing durable
238                // was written, the engine still holds the dangling
239                // `ActivityStarted`, and the next boot re-dispatches the row.
240                // Withdraw the token this pass minted so the site's generation
241                // is not left waiting on a completion that will never come.
242                if let Err(error) = fences.revoke(
243                    &row.workflow_id,
244                    &ActivityId::from_sequence_position(row.ordinal),
245                    &token,
246                ) {
247                    tracing::warn!(
248                        workflow_id = %row.workflow_id,
249                        ordinal = row.ordinal,
250                        %error,
251                        "could not withdraw the completion token of a parked declared body"
252                    );
253                }
254                tracing::info!(
255                    operation = "declared_command_outbox_dispatch",
256                    workflow_id = %row.workflow_id,
257                    ordinal = row.ordinal,
258                    "declared body parked: this server is draining and starts no new work"
259                );
260                return;
261            }
262            if let Err(error) = Self::complete(sink.as_ref(), &row, run_id, token, ended) {
263                tracing::error!(
264                    operation = "declared_command_outbox_dispatch",
265                    workflow_id = %row.workflow_id,
266                    ordinal = row.ordinal,
267                    %error,
268                    "a declared body ran for an outbox row but its completion was refused"
269                );
270            }
271        });
272        Ok(())
273    }
274}
275
276/// Classify an executor failure string into the completion the engine reads
277/// from a worker. The prefix vocabulary is the executor's own
278/// (`terminal:` / `policy_refused:` / `retryable:`); the engine's `timeout:`
279/// and anything unprefixed are left to the retry loop as retryable, with the
280/// text carried whole so a reader sees what the executor said.
281fn classify_failure(reason: &str) -> ActivityError {
282    let (kind, message) = if let Some(rest) = reason.strip_prefix("terminal:") {
283        (ActivityErrorKind::Terminal, rest.to_owned())
284    } else if let Some(rest) = reason.strip_prefix("policy_refused:") {
285        (ActivityErrorKind::PolicyRefused, rest.to_owned())
286    } else if let Some(rest) = reason.strip_prefix("retryable:") {
287        (ActivityErrorKind::Retryable, rest.to_owned())
288    } else {
289        (ActivityErrorKind::Retryable, reason.to_owned())
290    };
291    ActivityError {
292        kind,
293        message,
294        details: None,
295    }
296}
297
298#[async_trait]
299impl OutboxRowDispatch for DeclaredBodyOutboxDispatch {
300    async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
301        let Some(run_id) = row.run_id.as_ref() else {
302            // No run to look the body up under: the worker path refuses this
303            // row by name too, so hand it there and let that refusal stand.
304            return self.inner.dispatch(row).await;
305        };
306        let run = DispatchingRun {
307            workflow_id: &row.workflow_id,
308            run_id,
309        };
310        match self
311            .bodies
312            .body_for(&row.task_queue, &row.activity_type, run)
313        {
314            DeclaredBodyLookup::None => self.inner.dispatch(row).await,
315            DeclaredBodyLookup::Unreadable(reason) => {
316                tracing::error!(
317                    operation = "declared_command_outbox_dispatch",
318                    workflow_id = %row.workflow_id,
319                    activity_name = %row.activity_type,
320                    task_queue = %row.task_queue,
321                    %reason,
322                    "declared-body catalog read failed; delegating the outbox row to the worker path"
323                );
324                self.inner.dispatch(row).await
325            }
326            DeclaredBodyLookup::Ambiguous { declaring } => {
327                let run_id = Self::run_id(row)?;
328                let token = self.mint_token(row, &run_id)?;
329                let refusal =
330                    ambiguous_body_refusal(&row.activity_type, &row.task_queue, &declaring);
331                Self::complete(self.sink.as_ref(), row, run_id, token, Err(refusal))
332            }
333            DeclaredBodyLookup::Declared(contract) => self.place(row, contract),
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use std::sync::{Arc, Mutex};
341    use std::time::Duration;
342
343    use aion_core::{ActivityId, Payload, RunId, WorkflowId};
344    use aion_package::ActionBodyContract;
345    use aion_store::{OutboxRow, OutboxStatus};
346    use async_trait::async_trait;
347
348    use super::super::declared_body::{DeclaredBodies, DeclaredBodyLookup, DispatchingRun};
349    use super::super::declared_body_cancel::DeclaredCommandAttempts;
350    use super::super::workspace_root::WorkspaceRoot;
351    use super::*;
352
353    type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
354
355    /// The transport this decorator wraps. Records every row it is handed and
356    /// answers as a queue with no live poller would.
357    struct RecordingInner {
358        rows: Arc<Mutex<Vec<String>>>,
359    }
360
361    #[async_trait]
362    impl OutboxRowDispatch for RecordingInner {
363        async fn dispatch(&self, row: &OutboxRow) -> Result<(), ServerError> {
364            self.rows
365                .lock()
366                .map_err(|_| ServerError::worker_dispatch("", "", "recording inner poisoned"))?
367                .push(row.dispatch_key.clone());
368            Err(ServerError::worker_dispatch(
369                &row.namespace,
370                &row.activity_type,
371                "no worker can currently serve this queue (NO_LIVE_POLLERS)",
372            ))
373        }
374    }
375
376    struct FixedBodies {
377        lookup: DeclaredBodyLookup,
378    }
379
380    impl DeclaredBodies for FixedBodies {
381        fn body_for(
382            &self,
383            _task_queue: &str,
384            _action: &str,
385            _run: DispatchingRun<'_>,
386        ) -> DeclaredBodyLookup {
387            self.lookup.clone()
388        }
389    }
390
391    /// The completion sink: records what the decorator feeds it.
392    #[derive(Default)]
393    struct RecordingSink {
394        completions: Arc<Mutex<Vec<ActivityCompletion>>>,
395    }
396
397    impl ActivityCompletionSink for RecordingSink {
398        fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
399            self.completions
400                .lock()
401                .map_err(|_| ServerError::worker_dispatch("", "", "recording sink poisoned"))?
402                .push(completion);
403            Ok(())
404        }
405
406        fn park_activity(
407            &self,
408            _workflow_id: &WorkflowId,
409            _activity_id: &ActivityId,
410        ) -> Result<(), ServerError> {
411            Ok(())
412        }
413    }
414
415    const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
416        Some(capacity) => capacity,
417        None => unreachable!(),
418    };
419
420    fn executor() -> Arc<DeclaredCommandExecutor> {
421        let store: Arc<dyn aion_store::ObservabilityStore> =
422            Arc::new(aion_store::InMemoryObservabilityStore::default());
423        let transcript = crate::activity_publisher::ActivityEventPublisher::new(
424            store,
425            TRANSCRIPT_CAPACITY,
426            crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
427        );
428        Arc::new(DeclaredCommandExecutor::new(
429            DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
430            tokio::runtime::Handle::current(),
431            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
432            transcript,
433        ))
434    }
435
436    fn row(ordinal: u64, activity_type: &str, input: &str) -> OutboxRow {
437        let workflow_id = WorkflowId::new(uuid::Uuid::new_v4());
438        OutboxRow {
439            dispatch_key: format!("{workflow_id}:{ordinal}"),
440            workflow_id,
441            ordinal,
442            run_id: Some(RunId::new(uuid::Uuid::new_v4())),
443            namespace: "default".to_owned(),
444            task_queue: "json_box".to_owned(),
445            node: None,
446            activity_type: activity_type.to_owned(),
447            input: Payload::new(ContentType::Json, input.as_bytes().to_vec()),
448            status: OutboxStatus::Claimed,
449            attempt: 0,
450            started_attempt: 1,
451            visible_after: chrono::Utc::now(),
452            claimed_at: Some(chrono::Utc::now()),
453            failure_delivered: false,
454        }
455    }
456
457    struct Rig {
458        dispatch: DeclaredBodyOutboxDispatch,
459        inner_rows: Arc<Mutex<Vec<String>>>,
460        completions: Arc<Mutex<Vec<ActivityCompletion>>>,
461        fences: CompletionFences,
462    }
463
464    fn rig(lookup: DeclaredBodyLookup) -> Rig {
465        let inner_rows = Arc::new(Mutex::new(Vec::new()));
466        let bodies = DeclaredBodySource::default();
467        bodies.install(Arc::new(FixedBodies { lookup }));
468        let sink = RecordingSink::default();
469        let completions = Arc::clone(&sink.completions);
470        let fences = CompletionFences::default();
471        let dispatch = DeclaredBodyOutboxDispatch::new(
472            Arc::new(RecordingInner {
473                rows: Arc::clone(&inner_rows),
474            }),
475            bodies,
476            executor(),
477            fences.clone(),
478            Arc::new(sink),
479            tokio::runtime::Handle::current(),
480        );
481        Rig {
482            dispatch,
483            inner_rows,
484            completions,
485            fences,
486        }
487    }
488
489    async fn await_completions(
490        completions: &Arc<Mutex<Vec<ActivityCompletion>>>,
491        count: usize,
492    ) -> Result<Vec<ActivityCompletion>, Box<dyn std::error::Error + Send + Sync>> {
493        let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
494        loop {
495            let seen = completions
496                .lock()
497                .map_err(|_| "completions poisoned")?
498                .iter()
499                .map(|completion| ActivityCompletion {
500                    workflow_id: completion.workflow_id.clone(),
501                    activity_id: completion.activity_id.clone(),
502                    run_id: completion.run_id.clone(),
503                    completion_token: completion.completion_token.clone(),
504                    outcome: completion.outcome.clone(),
505                })
506                .collect::<Vec<_>>();
507            if seen.len() >= count {
508                return Ok(seen);
509            }
510            if tokio::time::Instant::now() >= deadline {
511                return Err(format!("expected {count} completion(s), saw {}", seen.len()).into());
512            }
513            tokio::time::sleep(Duration::from_millis(20)).await;
514        }
515    }
516
517    /// THE PIN (aion#193): a row whose action declares a body is executed at
518    /// the server and completed through the sink — the worker path is never
519    /// consulted. Before this decorator existed, this row reached the
520    /// registry and parked as `NO_LIVE_POLLERS`.
521    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
522    async fn a_declared_body_row_executes_at_the_server_and_completes_through_the_sink()
523    -> TestResult {
524        let rig = rig(DeclaredBodyLookup::Declared(ActionBodyContract::Run {
525            command: "printf 'sha=%s' {{sha}}".to_owned(),
526        }));
527        let row = row(3, "size_of", r#"{"sha":"abc123"}"#);
528        rig.dispatch.dispatch(&row).await?;
529        assert!(
530            rig.inner_rows
531                .lock()
532                .map_err(|_| "inner poisoned")?
533                .is_empty(),
534            "a declared body must never reach the worker path"
535        );
536        let completions = await_completions(&rig.completions, 1).await?;
537        let completion = &completions[0];
538        assert_eq!(completion.workflow_id, row.workflow_id);
539        assert_eq!(
540            completion.activity_id,
541            ActivityId::from_sequence_position(3)
542        );
543        assert_eq!(completion.run_id, row.run_id);
544        let ActivityCompletionOutcome::Succeeded(payload) = &completion.outcome else {
545            return Err(format!("expected a success, got {:?}", completion.outcome).into());
546        };
547        let encoded = std::str::from_utf8(payload.bytes())?;
548        let outcome: serde_json::Value = serde_json::from_str(encoded)?;
549        assert_eq!(
550            outcome["stdout"], "sha=abc123",
551            "the body ran with the row's input bound"
552        );
553        assert_eq!(outcome["exit_code"], 0);
554        // The token was minted from the shared fences for THIS attempt: the
555        // fences accept it exactly once, as they would a worker's.
556        rig.fences.accept(
557            &row.workflow_id,
558            &completion.activity_id,
559            &completion.completion_token,
560        )?;
561        Ok(())
562    }
563
564    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
565    async fn a_row_without_a_body_goes_to_the_worker_path_untouched() -> TestResult {
566        let rig = rig(DeclaredBodyLookup::None);
567        let row = row(1, "size_of", r#"{"sha":"abc123"}"#);
568        let refused = rig.dispatch.dispatch(&row).await;
569        assert!(
570            refused.is_err(),
571            "the inner transport's refusal must surface as the row's"
572        );
573        assert_eq!(
574            *rig.inner_rows.lock().map_err(|_| "inner poisoned")?,
575            vec![row.dispatch_key.clone()]
576        );
577        assert!(
578            rig.completions
579                .lock()
580                .map_err(|_| "completions poisoned")?
581                .is_empty()
582        );
583        Ok(())
584    }
585
586    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
587    async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
588        let rig = rig(DeclaredBodyLookup::Unreadable("catalog offline".to_owned()));
589        let row = row(1, "size_of", r#"{"sha":"abc123"}"#);
590        let _ = rig.dispatch.dispatch(&row).await;
591        assert_eq!(
592            *rig.inner_rows.lock().map_err(|_| "inner poisoned")?,
593            vec![row.dispatch_key.clone()]
594        );
595        Ok(())
596    }
597
598    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
599    async fn an_ambiguous_body_is_a_terminal_failure_delivered_to_the_workflow() -> TestResult {
600        let rig = rig(DeclaredBodyLookup::Ambiguous {
601            declaring: Vec::new(),
602        });
603        let row = row(2, "size_of", r#"{"sha":"abc123"}"#);
604        rig.dispatch.dispatch(&row).await?;
605        assert!(
606            rig.inner_rows
607                .lock()
608                .map_err(|_| "inner poisoned")?
609                .is_empty()
610        );
611        let completions = await_completions(&rig.completions, 1).await?;
612        let ActivityCompletionOutcome::Failed(error) = &completions[0].outcome else {
613            return Err("expected a failure".into());
614        };
615        assert_eq!(error.kind, ActivityErrorKind::Terminal);
616        Ok(())
617    }
618
619    /// A `run` body that exits nonzero is a RETRYABLE failure of the attempt on
620    /// the direct path (the executor classifies it, naming the program and its
621    /// exit); this path must deliver exactly that classification, so the retry
622    /// loop treats a fork member's red as it treats a plain statement's.
623    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
624    async fn a_failing_body_completes_as_the_executors_retryable_failure() -> TestResult {
625        let rig = rig(DeclaredBodyLookup::Declared(ActionBodyContract::Run {
626            command: "sh -c 'exit 7'".to_owned(),
627        }));
628        let row = row(4, "size_of", "{}");
629        rig.dispatch.dispatch(&row).await?;
630        let completions = await_completions(&rig.completions, 1).await?;
631        let ActivityCompletionOutcome::Failed(error) = &completions[0].outcome else {
632            return Err(format!("expected a failure, got {:?}", completions[0].outcome).into());
633        };
634        assert_eq!(error.kind, ActivityErrorKind::Retryable);
635        assert!(
636            error.message.contains("exited 7"),
637            "the executor's own sentence must travel: {}",
638            error.message
639        );
640        Ok(())
641    }
642
643    #[test]
644    fn failure_classification_follows_the_executor_prefixes() {
645        assert_eq!(
646            classify_failure("terminal:x").kind,
647            ActivityErrorKind::Terminal
648        );
649        assert_eq!(classify_failure("terminal:x").message, "x");
650        assert_eq!(
651            classify_failure("policy_refused:y").kind,
652            ActivityErrorKind::PolicyRefused
653        );
654        assert_eq!(
655            classify_failure("retryable:z").kind,
656            ActivityErrorKind::Retryable
657        );
658        let timeout = classify_failure("timeout:attempt outlived its bound");
659        assert_eq!(timeout.kind, ActivityErrorKind::Retryable);
660        assert_eq!(timeout.message, "timeout:attempt outlived its bound");
661    }
662}