Skip to main content

aion_server/worker/
declared_body.rs

1//! Server-side execution of declared action bodies.
2//!
3//! An action whose deployed contract carries an [`ActionBodyContract`] is
4//! executed BY THE SERVER, with no connected worker: the dispatch is
5//! intercepted at the [`ActivityDispatcher`] seam before task-queue routing,
6//! the declared command runs through the worker SDK's own executor
7//! ([`aion_worker::shell::ShellAction`] — argv-element substitution, no
8//! shell, process-group containment), and the result flows back through the
9//! engine's normal completion path. The engine still schedules, records, and
10//! replays the activity exactly as if a worker had served it.
11//!
12//! Actions with no declared body are delegated to the wrapped production
13//! dispatcher unchanged, so remote workers keep working exactly as before.
14//!
15//! # The command is readable while it runs
16//!
17//! The executing activity carries a live transcript seam
18//! ([`ActivityContext::with_transcript`](aion_worker::ActivityContext::with_transcript)),
19//! so every line the command writes to stdout or stderr is published onto the
20//! server's transcript sequencer AS IT ARRIVES — the same stream, envelope, and
21//! cursor reads an agent step's transcript uses (see
22//! [`super::declared_body_transcript`]). The activity's recorded result is
23//! untouched by this: it still carries the command's complete output.
24//!
25//! # The command can be stopped, by its bound and by its run
26//!
27//! Two things end a server-executed command early, and both reach the same
28//! cancellation the worker path already acts on — `SIGTERM` → grace →
29//! `SIGKILL` across the whole process group, with the verdict withheld until
30//! the group has been proven gone.
31//!
32//! The first is the attempt's own deadline. A dispatch carrying an authored
33//! per-attempt timeout (#223) ends its command at that bound HERE, in the
34//! server, where the process is — see [`run_bounded`]. The engine's own
35//! deadline stops the run WAITING and cannot reach a process, which is the
36//! right division of labour for a remote worker and no division at all for a
37//! body the server itself started. A dispatch that authored no bound is
38//! unbounded, exactly as before: the server adds no deadline of its own.
39//!
40//! The second is the run being cancelled. Every executing attempt registers in
41//! [`super::declared_body_cancel::DeclaredCommandAttempts`] for exactly as long
42//! as its command runs, which is how the cancel path reaches an activity no
43//! worker holds and no heartbeat tracks. An attempt that cannot register is
44//! refused rather than run: a command a cancelled run could not stop is the
45//! defect the registration exists to prevent.
46
47use std::collections::BTreeMap;
48use std::future::Future;
49use std::sync::{Arc, OnceLock};
50
51use aion::{ActivityDispatch, ActivityDispatcher};
52use aion_package::{ActionBodyContract, ContentHash};
53use aion_worker::ActivityFailure;
54use aion_worker::shell::{ShellAction, ShellOutcome};
55
56use super::declared_body_ambiguity::{DeclaringVersion, ambiguous_body_refusal};
57use super::declared_body_cancel::DeclaredCommandAttempts;
58use super::declared_body_selection::select_declared_body;
59use super::declared_body_transcript::publish_declared_transcript;
60use super::workspace_root::{WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot};
61use crate::activity_publisher::ActivityEventPublisher;
62
63/// What a declared-body lookup found for one `(task_queue, action)` address.
64#[derive(Clone, Debug)]
65pub enum DeclaredBodyLookup {
66    /// No retained contract declares a body for this action — it is a
67    /// requirement on an out-of-band worker and must be delegated.
68    None,
69    /// Exactly one distinct body is declared across every retained package
70    /// version. Safe to execute.
71    Declared(ActionBodyContract),
72    /// Retained package versions declare DIFFERENT bodies for this action.
73    /// Executing one of them would guess which deploy the running workflow
74    /// meant, so the dispatch is refused by name instead.
75    Ambiguous {
76        /// Every retained version that declares a body for this action, in
77        /// catalog order. Carried rather than counted because the refusal has
78        /// to name the versions the operator must retire — a bare count leaves
79        /// them holding a terminal error with no way to act on it.
80        declaring: Vec<DeclaringVersion>,
81    },
82    /// The catalog could not be read. The reader reports why; the dispatch
83    /// is delegated so a readable worker path can still serve it.
84    Unreadable(String),
85}
86
87/// Which run a declared-body lookup is being made for.
88///
89/// A body is a property of the run's own package version, not of the queue, so
90/// the lookup cannot answer correctly without knowing whose dispatch it is —
91/// see [`super::declared_body_selection`].
92#[derive(Clone, Copy, Debug)]
93pub struct DispatchingRun<'a> {
94    /// The workflow the activity belongs to.
95    pub workflow_id: &'a aion_core::WorkflowId,
96    /// The concrete run within that workflow.
97    pub run_id: &'a aion_core::RunId,
98}
99
100/// A reader over the deployed contracts' declared action bodies.
101pub trait DeclaredBodies: Send + Sync {
102    /// Look up the declared body for `action` on `task_queue`, as the run
103    /// issuing the dispatch sees it.
104    fn body_for(
105        &self,
106        task_queue: &str,
107        action: &str,
108        run: DispatchingRun<'_>,
109    ) -> DeclaredBodyLookup;
110}
111
112/// Shared, install-once handle the dispatcher holds from construction and the
113/// boot path fills in once the engine exists.
114///
115/// Mirrors [`super::QueueDeclarationSource`]: the dispatcher is built before
116/// the engine, so the seam it consults is handed over afterwards through a
117/// clone of this handle rather than by rebuilding the dispatcher.
118#[derive(Clone, Default)]
119pub struct DeclaredBodySource {
120    inner: Arc<OnceLock<Arc<dyn DeclaredBodies>>>,
121}
122
123impl std::fmt::Debug for DeclaredBodySource {
124    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        formatter
126            .debug_struct("DeclaredBodySource")
127            .field("installed", &self.inner.get().is_some())
128            .finish()
129    }
130}
131
132impl DeclaredBodySource {
133    /// Install the reader. A second install is ignored and logged: the source
134    /// is process-wide and must not silently change identity.
135    pub fn install(&self, source: Arc<dyn DeclaredBodies>) {
136        if self.inner.set(source).is_err() {
137            tracing::warn!("declared body source already installed; ignoring duplicate set");
138        }
139    }
140
141    /// Look up the declared body, or [`DeclaredBodyLookup::None`] when no
142    /// reader is installed yet.
143    ///
144    /// An uninstalled consult is stated at ERROR before delegating to the
145    /// worker path, never silently: a dispatch can only reach this seam from
146    /// a live run, and a live run's deploy is durable — so "nothing installed
147    /// yet" is a boot-ordering defect, not an empty catalog. This exact
148    /// silence was #266 Defect A: startup recovery replay re-dispatched
149    /// adopted in-flight declared-body activities before
150    /// `install_engine_backed_seams` filled this source, and every one fell
151    /// through here to a queue with no pollers and parked forever. The fix
152    /// (deferred startup recovery) removes the caller; this arm stays loud so
153    /// any future pre-install dispatch path names itself in the log instead
154    /// of stranding runs silently.
155    #[must_use]
156    pub fn body_for(
157        &self,
158        task_queue: &str,
159        action: &str,
160        run: DispatchingRun<'_>,
161    ) -> DeclaredBodyLookup {
162        self.inner.get().map_or_else(
163            || {
164                tracing::error!(
165                    operation = "declared_command_dispatch",
166                    task_queue,
167                    action,
168                    workflow_id = %run.workflow_id,
169                    run_id = %run.run_id,
170                    "declared body source consulted before it was installed; the dispatch \
171                     falls through to the worker path and will park if the queue's only \
172                     service is its declared bodies (#266 boot-ordering defect)"
173                );
174                DeclaredBodyLookup::None
175            },
176            |source| source.body_for(task_queue, action, run),
177        )
178    }
179}
180
181/// Reads declared bodies out of the engine's live workflow catalog.
182pub struct EngineDeclaredBodies {
183    engine: Arc<aion::Engine>,
184}
185
186impl EngineDeclaredBodies {
187    /// Build a reader over `engine`'s catalog.
188    #[must_use]
189    pub const fn new(engine: Arc<aion::Engine>) -> Self {
190        Self { engine }
191    }
192}
193
194impl std::fmt::Debug for EngineDeclaredBodies {
195    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        formatter.write_str("EngineDeclaredBodies")
197    }
198}
199
200impl EngineDeclaredBodies {
201    /// The package version `run` is pinned to, or `None` when the registry
202    /// cannot name it.
203    ///
204    /// Two ways to reach `None`, and both are reported rather than swallowed:
205    /// the run has no handle (it left the registry), or the registry could not
206    /// be read at all. Neither is a reason to guess a body — the caller falls
207    /// back to the queue-wide reading, which refuses on disagreement.
208    fn version_of(&self, run: DispatchingRun<'_>) -> Option<ContentHash> {
209        match self.engine.registry().get(run.workflow_id, run.run_id) {
210            Ok(Some(handle)) => Some(handle.loaded_version().clone()),
211            Ok(None) => {
212                tracing::warn!(
213                    operation = "declared_command_dispatch",
214                    workflow_id = %run.workflow_id,
215                    run_id = %run.run_id,
216                    "no registry handle for the dispatching run; resolving its body \
217                     from the whole queue instead of from its own package version"
218                );
219                None
220            }
221            Err(error) => {
222                tracing::error!(
223                    operation = "declared_command_dispatch",
224                    workflow_id = %run.workflow_id,
225                    run_id = %run.run_id,
226                    %error,
227                    "registry unreadable while resolving the dispatching run's version; \
228                     resolving its body from the whole queue instead"
229                );
230                None
231            }
232        }
233    }
234}
235
236impl DeclaredBodies for EngineDeclaredBodies {
237    fn body_for(
238        &self,
239        task_queue: &str,
240        action: &str,
241        run: DispatchingRun<'_>,
242    ) -> DeclaredBodyLookup {
243        let contracts = match self.engine.worker_contracts_for_queue(task_queue) {
244            Ok(contracts) => contracts,
245            Err(error) => return DeclaredBodyLookup::Unreadable(error.to_string()),
246        };
247        // The RAW retained set is the right input here, unlike worker admission
248        // (see `Engine::worker_contracts_for_queue`): a run pinned to a version
249        // nothing else can reach still has to execute that version's body. What
250        // narrows the answer is the run's own identity, not reachability.
251        select_declared_body(&contracts, action, self.version_of(run).as_ref())
252    }
253}
254
255/// The dispatcher decorator that executes declared bodies at the server.
256///
257/// Wraps the production dispatcher. Consults the declared-body source before
258/// every dispatch; delegates untouched whenever the action carries no body.
259/// The execution itself lives in [`DeclaredCommandExecutor`], which the
260/// outbox delivery path shares (aion#193): one executor, two dispatch paths.
261pub struct DeclaredCommandDispatcher {
262    inner: Arc<dyn ActivityDispatcher>,
263    bodies: DeclaredBodySource,
264    executor: Arc<DeclaredCommandExecutor>,
265}
266
267/// Executes a declared body at the server: the process-group-contained
268/// command, its transcript, its cancel-path registration, and the bound the
269/// dispatch authored. Owns no routing decision — a caller has already looked
270/// the body up — so it is the ONE implementation both the direct dispatch
271/// decorator above and the outbox row decorator
272/// ([`super::declared_body_outbox`]) run, and the two paths cannot execute a
273/// body differently.
274pub struct DeclaredCommandExecutor {
275    attempts: DeclaredCommandAttempts,
276    tokio: tokio::runtime::Handle,
277    workspace_root: WorkspaceRoot,
278    transcript: ActivityEventPublisher,
279}
280
281impl DeclaredCommandExecutor {
282    /// Build an executor: registering every attempt it executes in `attempts`
283    /// so the run's cancel can reach it, expanding `{workspace_root}` with the
284    /// server-resolved `workspace_root`, and streaming each executed command's
285    /// output onto `transcript`.
286    #[must_use]
287    pub fn new(
288        attempts: DeclaredCommandAttempts,
289        tokio: tokio::runtime::Handle,
290        workspace_root: WorkspaceRoot,
291        transcript: ActivityEventPublisher,
292    ) -> Self {
293        Self {
294            attempts,
295            tokio,
296            workspace_root,
297            transcript,
298        }
299    }
300
301    /// Run the body `contract` declares for `request` and encode how the
302    /// attempt ended onto the FFI string contract — the same string the
303    /// direct path returns to the engine, produced by the same code.
304    ///
305    /// # Errors
306    ///
307    /// The prefixed failure string (`terminal:` / `retryable:` /
308    /// `policy_refused:` / `timeout:`), or the parked-activity sentinel when
309    /// this server is draining and starts no new work.
310    pub fn execute(
311        &self,
312        request: &ActivityDispatch,
313        contract: &ActionBodyContract,
314    ) -> Result<String, String> {
315        match contract {
316            ActionBodyContract::Run { command } => self.run_declared_command(request, command),
317            ActionBodyContract::Command { capture, command } => {
318                self.run_declared_command_body(request, *capture, *command.clone())
319            }
320        }
321    }
322}
323
324impl DeclaredCommandDispatcher {
325    /// Wrap `inner`, consulting `bodies` before every dispatch, registering
326    /// every attempt it executes in `attempts` so the run's cancel can reach
327    /// it, expanding `{workspace_root}` in declared commands with the
328    /// server-resolved `workspace_root`, and streaming each executed command's
329    /// output onto `transcript` — the deployment's one transcript sequencer,
330    /// shared with every agent step.
331    ///
332    /// `attempts` is required rather than optional because a dispatcher without
333    /// one would execute commands nothing could stop, which is precisely the
334    /// state this argument exists to end.
335    #[must_use]
336    pub fn new(
337        inner: Arc<dyn ActivityDispatcher>,
338        bodies: DeclaredBodySource,
339        attempts: DeclaredCommandAttempts,
340        tokio: tokio::runtime::Handle,
341        workspace_root: WorkspaceRoot,
342        transcript: ActivityEventPublisher,
343    ) -> Self {
344        Self {
345            inner,
346            bodies,
347            executor: Arc::new(DeclaredCommandExecutor::new(
348                attempts,
349                tokio,
350                workspace_root,
351                transcript,
352            )),
353        }
354    }
355
356    /// The executor this decorator runs bodies through, for the outbox
357    /// delivery path to share (aion#193).
358    #[must_use]
359    pub fn executor(&self) -> Arc<DeclaredCommandExecutor> {
360        Arc::clone(&self.executor)
361    }
362}
363
364impl DeclaredCommandExecutor {
365    /// Parse the declared command into the executor's action, with the
366    /// server-resolved `{workspace_root}` already spliced in.
367    ///
368    /// Ratification condition (#139): a body that USES the placeholder is
369    /// refused terminally, by name, when the root cannot resolve to an absolute
370    /// directory that exists — no fallback to cwd, temp, or anything else. A
371    /// body without the placeholder never reaches the resolution at all
372    /// (`expand` returns `Ok(None)` untouched).
373    fn declared_action(
374        &self,
375        request: &ActivityDispatch,
376        command: &str,
377    ) -> Result<ShellAction, String> {
378        let expanded = self.workspace_root.expand(command).map_err(|error| {
379            format!(
380                "terminal:declared body for action `{name}` uses the {placeholder} \
381                 placeholder and cannot dispatch: {error}",
382                name = request.name,
383                placeholder = WORKSPACE_ROOT_PLACEHOLDER,
384            )
385        })?;
386        if let Some(expansion) = &expanded {
387            tracing::info!(
388                operation = "declared_command_dispatch",
389                workflow_id = %request.workflow_id,
390                activity_id = %request.activity_id,
391                activity_name = %request.name,
392                task_queue = %request.task_queue,
393                attempt = request.attempt,
394                workspace_root = %expansion.workspace_root,
395                "expanded the workspace-root placeholder in the declared command"
396            );
397        }
398        let command = expanded
399            .as_ref()
400            .map_or(command, |expansion| expansion.command.as_str());
401        ShellAction::new(command).map_err(|error| {
402            // The AWL checker refuses these at compile time, so reaching this
403            // arm means a defective contract got deployed — name the defect
404            // rather than hiding it behind a generic dispatch failure.
405            format!("terminal:declared command failed to parse at dispatch: {error}")
406        })
407    }
408
409    /// Put the attempt on this server's cancel path BEFORE its command starts.
410    ///
411    /// The returned guard keeps it there for exactly as long as the command
412    /// runs, so an attempt that ended — completed, failed, or unwound — can
413    /// never be signalled afterwards. A registration that cannot be made is a
414    /// command nothing could stop, so the dispatch is refused rather than run:
415    /// an uncancellable command on the operator's machine is the whole defect
416    /// this registration exists to prevent, and starting one to avoid an error
417    /// message would be choosing it.
418    pub(super) fn join_cancel_path(
419        &self,
420        request: &ActivityDispatch,
421        cancellation: &aion_worker::ActivityCancellationHandle,
422    ) -> Result<super::DeclaredAttemptRegistration, String> {
423        self.attempts
424            .register(
425                super::AttemptKey::new(
426                    request.workflow_id.clone(),
427                    request.run_id.clone(),
428                    request.activity_id.clone(),
429                    request.attempt,
430                ),
431                cancellation.clone(),
432            )
433            .map_err(|error| match error {
434                // A draining refusal is a park, not a failure: same sentinel a
435                // worker dispatch returns mid-drain. Nothing is recorded, the
436                // engine parks the attempt, and the next boot re-dispatches
437                // it — a terminal error here would fail the workflow for the
438                // crime of the operator stopping the server.
439                crate::error::ServerError::DrainingRefusedDeclaredAttempt { .. } => {
440                    tracing::info!(
441                        operation = "declared_command_dispatch",
442                        workflow_id = %request.workflow_id,
443                        activity_id = %request.activity_id,
444                        activity_name = %request.name,
445                        task_queue = %request.task_queue,
446                        attempt = request.attempt,
447                        "declared command parked: this server is draining and starts no new work"
448                    );
449                    aion::PARKED_ACTIVITY_REASON.to_owned()
450                }
451                other => format!(
452                    "terminal:declared body for action `{name}` cannot dispatch: the attempt \
453                     could not join this server's cancel path, and a command a cancelled run \
454                     could not stop must not be started: {other}",
455                    name = request.name,
456                ),
457            })
458    }
459
460    /// Execute one declared command attempt and encode the outcome onto the
461    /// FFI string contract (`retryable:`/`terminal:` on the error side).
462    fn run_declared_command(
463        &self,
464        request: &ActivityDispatch,
465        command: &str,
466    ) -> Result<String, String> {
467        let arguments = decode_arguments(&request.input)?;
468        let action = self.declared_action(request, command)?;
469        // The live transcript seam for this attempt. The context owns the
470        // sending end, so dropping it after the run closes the stream and ends
471        // the pump — which is then awaited, so no observed line is abandoned
472        // unpublished when the command finishes.
473        let (events, drain) = tokio::sync::mpsc::unbounded_channel();
474        let (context, cancellation) = aion_worker::ActivityContext::with_transcript(
475            request.workflow_id.clone(),
476            request.run_id.clone(),
477            request.activity_id.clone(),
478            request.attempt,
479            events,
480        );
481        let registration = self.join_cancel_path(request, &cancellation)?;
482
483        tracing::info!(
484            operation = "declared_command_dispatch",
485            workflow_id = %request.workflow_id,
486            activity_id = %request.activity_id,
487            activity_name = %request.name,
488            task_queue = %request.task_queue,
489            attempt = request.attempt,
490            "executing declared action body at the server"
491        );
492        // All three 2026-08-16 anonymous deaths correlated with workflow
493        // execution and the third died on exactly this path; the breadcrumb
494        // makes the in-flight site a death-note fact, not a log inference.
495        crate::death_note::breadcrumb(&format!(
496            "declared-action start action={} workflow_id={} run_id={} activity_id={} attempt={}",
497            request.name, request.workflow_id, request.run_id, request.activity_id, request.attempt,
498        ));
499
500        // #223: the bound the DISPATCH authored, or `None` when it authored
501        // none. Read through the engine's own decoder so the server cannot
502        // answer "what did this document authorise" differently from the retry
503        // loop, and so an unbounded body stays unbounded — the server invents
504        // no deadline of its own.
505        let bound = aion::activity_timeout_from_config(&request.config);
506        let transcript = self.transcript.clone();
507        let ended = self.tokio.block_on(async move {
508            let pump = tokio::spawn(publish_declared_transcript(transcript, drain));
509            let ended = run_bounded(action.run(&arguments, &context), &cancellation, bound).await;
510            // Closing the seam is what ends the pump; the context holds it.
511            drop(context);
512            if let Err(error) = pump.await {
513                tracing::warn!(
514                    %error,
515                    operation = "declared_command_dispatch",
516                    "declared command transcript: the publishing task ended abnormally; some \
517                     output lines may not have been retained"
518                );
519            }
520            ended
521        });
522        // The command is over and its group is gone, so the attempt leaves the
523        // cancel path. Dropped explicitly, here and not earlier: while this
524        // lives, a cancel arriving mid-run still reaches the process.
525        drop(registration);
526
527        // Encoded from the STRUCT, exactly as it was before the shaping seam
528        // existed: `ShellOutcome`'s members serialize in declaration order and
529        // no map representation stands between them and the bytes.
530        encode_end(request, ended, |outcome| {
531            serde_json::to_string(&outcome).map_err(|error| {
532                ActivityFailure::terminal(format!(
533                    "declared command result failed to encode: {error}"
534                ))
535            })
536        })
537    }
538}
539
540/// Encode how the attempt ended onto the FFI string contract.
541///
542/// `shape` turns a successful command's outcome into the action's declared
543/// result, ENCODED: the outcome record serialized straight from its struct for
544/// a `run` body, and whatever the capture says for a `runs command` one.
545///
546/// It hands back the encoded string rather than a `serde_json::Value` on
547/// purpose. Round-tripping the outcome record through a `Value` would reorder
548/// its members whenever `serde_json`'s `preserve_order` feature is not
549/// unified into the build, so the same source could produce two different
550/// result byte strings for one activity depending on how it was compiled.
551///
552/// Three vocabularies, one per honest outcome: the encoded result, the
553/// classified failure the executor produced (`retryable:`/`terminal:`), and the
554/// engine's own `timeout:` reason for an attempt that outlived its authored
555/// bound.
556pub(super) fn encode_end(
557    request: &ActivityDispatch,
558    ended: AttemptEnd,
559    shape: impl FnOnce(ShellOutcome) -> Result<String, ActivityFailure>,
560) -> Result<String, String> {
561    let outcome = match ended {
562        AttemptEnd::Ran(outcome) => outcome,
563        AttemptEnd::Expired { bound, ran_anyway } => {
564            if let Some(exit_code) = ran_anyway {
565                // The command reached its own end inside the stopping window.
566                // Its result is discarded — the attempt is already recorded as
567                // having outlived its bound, and answering with a late success
568                // would contradict a terminal the run has already been told
569                // about — but the fact is said, not swallowed.
570                tracing::warn!(
571                    operation = "declared_command_dispatch",
572                    workflow_id = %request.workflow_id,
573                    activity_id = %request.activity_id,
574                    activity_name = %request.name,
575                    attempt = request.attempt,
576                    exit_code,
577                    bound_ms = bound.as_millis(),
578                    "the declared command finished while it was being stopped on its \
579                     authored bound; its result is discarded in favour of the timeout"
580                );
581            }
582            return Err(aion::activity_timeout_reason(bound));
583        }
584    };
585
586    match outcome.and_then(shape) {
587        Ok(encoded) => Ok(encoded),
588        Err(failure) => {
589            let prefix = match failure.classification() {
590                aion_worker::Classification::Retryable => "retryable",
591                aion_worker::Classification::PolicyRefused => "policy_refused",
592                aion_worker::Classification::Terminal => "terminal",
593            };
594            Err(format!("{prefix}:{}", failure.message()))
595        }
596    }
597}
598
599/// How one declared-command attempt ended.
600#[derive(Debug)]
601pub(super) enum AttemptEnd {
602    /// The command ran to its own end — completed, failed, or was stopped by
603    /// something other than the authored bound.
604    Ran(Result<ShellOutcome, ActivityFailure>),
605    /// The attempt outlived the per-attempt bound its dispatch authored, and
606    /// its process group has been stopped and PROVEN gone.
607    Expired {
608        /// The authored bound that fired, carried so the refusal can name it.
609        bound: std::time::Duration,
610        /// The exit code of a command that reached its own end inside the
611        /// stopping window, when that happened. `None` — the ordinary case —
612        /// means the command was still running when the bound was enforced.
613        ran_anyway: Option<i32>,
614    },
615}
616
617/// Run the declared command, ending it at the bound its dispatch authored.
618///
619/// # Why the server enforces a bound the engine already applies
620///
621/// The engine wraps every attempt in `tokio::time::timeout` at the same
622/// authored bound (`nif_activity_retry_dispatch::deliver_one_attempt`), and is
623/// explicit about what that achieves: "the dispatch future is DROPPED, which
624/// stops this run waiting and nothing more... the worker-side call runs on to
625/// its own end and its result is discarded". For a REMOTE worker that is
626/// someone else's machine and the right division of labour. For a declared body
627/// it is a process tree in the server's own process group hierarchy, on the
628/// operator's machine, with nothing left that could ever stop it — the run has
629/// already moved on.
630///
631/// So the bound is enforced HERE as well, where the process is. On expiry the
632/// activity's cancellation is signalled and the SAME run future is awaited to
633/// its end: [`aion_worker::run_cancellable_command`] does not return until
634/// `SIGTERM` → [`aion_worker::PROCESS_GROUP_TERMINATION_GRACE`] → `SIGKILL` has
635/// been delivered to the whole group and the group has been PROVEN gone. This
636/// therefore returns only once the command is genuinely stopped, and the
637/// termination ladder and its grace are the worker path's, not a second copy.
638///
639/// A dispatch that authored no bound is awaited exactly as before.
640pub(super) async fn run_bounded(
641    run: impl Future<Output = Result<ShellOutcome, ActivityFailure>>,
642    cancellation: &aion_worker::ActivityCancellationHandle,
643    bound: Option<std::time::Duration>,
644) -> AttemptEnd {
645    let Some(bound) = bound else {
646        return AttemptEnd::Ran(run.await);
647    };
648    tokio::pin!(run);
649    match tokio::time::timeout(bound, &mut run).await {
650        Ok(outcome) => AttemptEnd::Ran(outcome),
651        Err(_elapsed) => {
652            cancellation.cancel();
653            AttemptEnd::Expired {
654                bound,
655                ran_anyway: run.await.ok().map(|outcome| outcome.exit_code),
656            }
657        }
658    }
659}
660
661impl DeclaredCommandExecutor {
662    /// The deployment's transcript sequencer, for a sibling execution path.
663    pub(super) fn transcript(&self) -> ActivityEventPublisher {
664        self.transcript.clone()
665    }
666
667    /// The runtime handle a blocking dispatch drives its async run on.
668    pub(super) fn tokio(&self) -> &tokio::runtime::Handle {
669        &self.tokio
670    }
671
672    /// The server-resolved workspace root, for a sibling execution path.
673    pub(super) const fn workspace_root(&self) -> &WorkspaceRoot {
674        &self.workspace_root
675    }
676}
677
678impl std::fmt::Debug for DeclaredCommandDispatcher {
679    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680        formatter
681            .debug_struct("DeclaredCommandDispatcher")
682            .field("bodies", &self.bodies)
683            .finish_non_exhaustive()
684    }
685}
686
687impl ActivityDispatcher for DeclaredCommandDispatcher {
688    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
689        let run = DispatchingRun {
690            workflow_id: &request.workflow_id,
691            run_id: &request.run_id,
692        };
693        match self
694            .bodies
695            .body_for(&request.task_queue, &request.name, run)
696        {
697            DeclaredBodyLookup::None => self.inner.dispatch(request),
698            DeclaredBodyLookup::Unreadable(reason) => {
699                // Delegated, not refused: a catalog read failure must not
700                // strand a queue that live workers could still serve. Loud so
701                // an operator sees a bodied action falling through.
702                tracing::error!(
703                    operation = "declared_command_dispatch",
704                    workflow_id = %request.workflow_id,
705                    activity_name = %request.name,
706                    task_queue = %request.task_queue,
707                    %reason,
708                    "declared-body catalog read failed; delegating to the worker path"
709                );
710                self.inner.dispatch(request)
711            }
712            DeclaredBodyLookup::Ambiguous { declaring } => Err(ambiguous_body_refusal(
713                &request.name,
714                &request.task_queue,
715                &declaring,
716            )),
717            DeclaredBodyLookup::Declared(contract) => self.executor.execute(&request, &contract),
718        }
719    }
720}
721
722/// Decode the dispatch's JSON input into the declared action's arguments.
723///
724/// A declared action's parameters are named in its `.awl` declaration, so the
725/// input must be a JSON object; anything else cannot bind to `$name`
726/// references and is refused by shape. Retrying cannot change the input, so
727/// the refusal is terminal.
728pub(super) fn decode_arguments(input: &str) -> Result<BTreeMap<String, serde_json::Value>, String> {
729    let value: serde_json::Value = serde_json::from_str(input)
730        .map_err(|error| format!("terminal:declared command input is not valid JSON: {error}"))?;
731    match value {
732        serde_json::Value::Object(members) => Ok(members.into_iter().collect()),
733        other => Err(format!(
734            "terminal:declared command input must be a JSON object binding the action's \
735             parameters by name; got {}",
736            json_kind(&other)
737        )),
738    }
739}
740
741/// A JSON value's kind, named for a refusal message.
742const fn json_kind(value: &serde_json::Value) -> &'static str {
743    match value {
744        serde_json::Value::Null => "null",
745        serde_json::Value::Bool(_) => "a boolean",
746        serde_json::Value::Number(_) => "a number",
747        serde_json::Value::String(_) => "a string",
748        serde_json::Value::Array(_) => "an array",
749        serde_json::Value::Object(_) => "an object",
750    }
751}
752
753/// Containment of a server-executed body: the authored per-attempt bound, and
754/// the run's cancellation. Its own file because its subjects are live process
755/// trees rather than dispatcher shapes; it builds them out of the fixtures
756/// [`tests`] shares with it.
757#[cfg(test)]
758#[path = "declared_body_containment_tests.rs"]
759mod declared_body_containment_tests;
760
761#[cfg(test)]
762pub(super) mod tests {
763    use std::collections::BTreeMap;
764    use std::sync::{Arc, Mutex};
765
766    use aion::{ActivityDispatch, ActivityDispatcher};
767    use aion_core::{ActivityId, RunId, WorkflowId};
768    use aion_package::ActionBodyContract;
769
770    use aion_core::ActivityEventKind;
771    use aion_store::ActivityStreamKey;
772
773    use super::super::workspace_root::{WorkspaceRoot, WorkspaceRootError};
774    use super::{
775        ActivityEventPublisher, DeclaredBodies, DeclaredBodyLookup, DeclaredBodySource,
776        DeclaredCommandAttempts, DeclaredCommandDispatcher, DeclaringVersion, DispatchingRun,
777        decode_arguments,
778    };
779
780    /// What a test returns. Every fallible step is carried rather than
781    /// unwrapped, because the workspace denies panicking accessors in test
782    /// code as firmly as in library code.
783    pub(crate) type TestResult = Result<(), Box<dyn std::error::Error>>;
784
785    /// Inner dispatcher that records whether it was reached.
786    struct RecordingInner {
787        reached: Arc<Mutex<Vec<String>>>,
788        reply: Result<String, String>,
789    }
790
791    impl ActivityDispatcher for RecordingInner {
792        fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
793            match self.reached.lock() {
794                Ok(mut names) => names.push(request.name),
795                Err(poisoned) => poisoned.into_inner().push(request.name),
796            }
797            self.reply.clone()
798        }
799    }
800
801    struct FixedBodies {
802        lookup: DeclaredBodyLookup,
803    }
804
805    impl DeclaredBodies for FixedBodies {
806        fn body_for(
807            &self,
808            _task_queue: &str,
809            _action: &str,
810            _run: DispatchingRun<'_>,
811        ) -> DeclaredBodyLookup {
812            self.lookup.clone()
813        }
814    }
815
816    /// A reader that records whose dispatch it was asked about.
817    ///
818    /// The selection rule is unit-tested on its own inputs, which proves the
819    /// rule and nothing about the plumbing. This double closes that gap: it
820    /// captures the [`DispatchingRun`] the dispatcher hands over, so the
821    /// identity can be compared against the request it came from.
822    struct RecordingBodies {
823        seen: Arc<Mutex<Vec<(WorkflowId, RunId)>>>,
824    }
825
826    impl DeclaredBodies for RecordingBodies {
827        fn body_for(
828            &self,
829            _task_queue: &str,
830            _action: &str,
831            run: DispatchingRun<'_>,
832        ) -> DeclaredBodyLookup {
833            let observed = (run.workflow_id.clone(), run.run_id.clone());
834            match self.seen.lock() {
835                Ok(mut seen) => seen.push(observed),
836                Err(poisoned) => poisoned.into_inner().push(observed),
837            }
838            DeclaredBodyLookup::None
839        }
840    }
841
842    pub(crate) fn request(name: &str, input: &str) -> ActivityDispatch {
843        ActivityDispatch {
844            namespace: "default".to_owned(),
845            task_queue: "shell".to_owned(),
846            node: None,
847            workflow_id: WorkflowId::new_v4(),
848            run_id: RunId::new_v4(),
849            activity_id: ActivityId::from_sequence_position(1),
850            name: name.to_owned(),
851            input: input.to_owned(),
852            config: "{}".to_owned(),
853            attempt: 1,
854            labels: BTreeMap::new(),
855            advisory: false,
856        }
857    }
858
859    fn dispatcher(
860        lookup: DeclaredBodyLookup,
861        reply: Result<String, String>,
862    ) -> (DeclaredCommandDispatcher, Arc<Mutex<Vec<String>>>) {
863        // These tests exercise bodies without the placeholder, so the root's
864        // value is never read; it is an explicit existing directory rather
865        // than a default so nothing here depends on resolution.
866        let (decorated, reached, _transcript) = dispatcher_with_root(
867            lookup,
868            reply,
869            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
870        );
871        (decorated, reached)
872    }
873
874    /// The live-tail buffer these tests give their transcript sequencer. A
875    /// `const` match rather than an unwrap: the workspace denies panicking
876    /// accessors in test code as firmly as in library code.
877    const TRANSCRIPT_CAPACITY: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(64) {
878        Some(capacity) => capacity,
879        None => std::num::NonZeroUsize::MIN,
880    };
881
882    /// A dispatcher whose executing attempts nothing external will signal.
883    ///
884    /// Correct for every test whose subject runs to its own end. A test that
885    /// CANCELS its subject needs the registry the cancel signals through, and
886    /// uses [`dispatcher_with_attempts`] to hold the same instance.
887    pub(crate) fn dispatcher_with_root(
888        lookup: DeclaredBodyLookup,
889        reply: Result<String, String>,
890        workspace_root: WorkspaceRoot,
891    ) -> (
892        DeclaredCommandDispatcher,
893        Arc<Mutex<Vec<String>>>,
894        ActivityEventPublisher,
895    ) {
896        dispatcher_with_attempts(
897            lookup,
898            reply,
899            workspace_root,
900            DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
901        )
902    }
903
904    pub(super) fn dispatcher_with_attempts(
905        lookup: DeclaredBodyLookup,
906        reply: Result<String, String>,
907        workspace_root: WorkspaceRoot,
908        attempts: DeclaredCommandAttempts,
909    ) -> (
910        DeclaredCommandDispatcher,
911        Arc<Mutex<Vec<String>>>,
912        ActivityEventPublisher,
913    ) {
914        let reached = Arc::new(Mutex::new(Vec::new()));
915        let inner = RecordingInner {
916            reached: Arc::clone(&reached),
917            reply,
918        };
919        let bodies = DeclaredBodySource::default();
920        bodies.install(Arc::new(FixedBodies { lookup }));
921        let store: Arc<dyn aion_store::ObservabilityStore> =
922            Arc::new(aion_store::InMemoryObservabilityStore::default());
923        let transcript = ActivityEventPublisher::new(
924            store,
925            TRANSCRIPT_CAPACITY,
926            crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
927        );
928        let decorated = DeclaredCommandDispatcher::new(
929            Arc::new(inner),
930            bodies,
931            attempts,
932            tokio::runtime::Handle::current(),
933            workspace_root,
934            transcript.clone(),
935        );
936        (decorated, reached, transcript)
937    }
938
939    pub(crate) fn reached_names(reached: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
940        match reached.lock() {
941            Ok(names) => names.clone(),
942            Err(poisoned) => poisoned.into_inner().clone(),
943        }
944    }
945
946    #[tokio::test(flavor = "multi_thread")]
947    async fn a_bodiless_action_is_delegated_untouched() -> TestResult {
948        let (decorated, reached) =
949            dispatcher(DeclaredBodyLookup::None, Ok("\"worker-served\"".to_owned()));
950        let handle =
951            tokio::task::spawn_blocking(move || decorated.dispatch(request("plain", "{}")));
952        let result = handle.await?;
953        assert_eq!(result, Ok("\"worker-served\"".to_owned()));
954        assert_eq!(reached_names(&reached), vec!["plain".to_owned()]);
955        Ok(())
956    }
957
958    #[tokio::test(flavor = "multi_thread")]
959    async fn a_declared_body_executes_without_touching_the_worker_path() -> TestResult {
960        let (decorated, reached) = dispatcher(
961            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
962                command: "echo {{greeting}}".to_owned(),
963            }),
964            Err("terminal:the worker path must never be reached".to_owned()),
965        );
966        let handle = tokio::task::spawn_blocking(move || {
967            decorated.dispatch(request(
968                "greet",
969                "{\"greeting\":\"hello from the contract\"}",
970            ))
971        });
972        let result = handle.await?;
973        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
974        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
975        assert_eq!(outcome["stdout"], "hello from the contract");
976        assert_eq!(outcome["exit_code"], 0);
977        assert!(
978            reached_names(&reached).is_empty(),
979            "the worker path must not be consulted for a bodied action"
980        );
981        Ok(())
982    }
983
984    /// A draining server PARKS a declared dispatch instead of running it: the
985    /// dispatch returns the park sentinel (the same face a worker dispatch
986    /// wears mid-drain, so the engine records nothing and the next boot
987    /// re-dispatches), the command's process never starts, and the census the
988    /// drain gate waits on registers nothing — work arriving after `stop` can
989    /// neither launch nor hold the gate open.
990    #[tokio::test(flavor = "multi_thread")]
991    async fn a_draining_server_parks_a_declared_dispatch_without_starting_it() -> TestResult {
992        let marker =
993            std::env::temp_dir().join(format!("aion-drain-park-{}", uuid::Uuid::new_v4().simple()));
994        let drain = crate::shutdown::DrainState::default();
995        let attempts = DeclaredCommandAttempts::new(drain.clone());
996        let (decorated, reached, _transcript) = dispatcher_with_attempts(
997            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
998                command: format!("touch {}", marker.display()),
999            }),
1000            Err("terminal:the worker path must never be reached".to_owned()),
1001            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1002            attempts.clone(),
1003        );
1004        assert!(drain.begin(), "the first begin() must flip the latch");
1005
1006        let handle =
1007            tokio::task::spawn_blocking(move || decorated.dispatch(request("touch_marker", "{}")));
1008        let result = handle.await?;
1009
1010        assert_eq!(
1011            result,
1012            Err(aion::PARKED_ACTIVITY_REASON.to_owned()),
1013            "a drained-over declared dispatch must wear the park sentinel, not a failure"
1014        );
1015        assert!(
1016            !marker.exists(),
1017            "the declared command must never start on a draining server"
1018        );
1019        assert!(
1020            reached_names(&reached).is_empty(),
1021            "the park must not fall through to the worker path"
1022        );
1023        assert!(
1024            attempts
1025                .executing()
1026                .map_err(|error| format!("census read failed: {error}"))?
1027                .is_empty(),
1028            "a parked dispatch must leave no census entry to hold the drain gate open"
1029        );
1030        Ok(())
1031    }
1032
1033    /// THE MID-STEP ANSWER: a server-run declared body's output reaches the
1034    /// deployment's transcript sequencer as one event per line, on both streams,
1035    /// keyed to the dispatch's own `(workflow, activity, attempt)` — the same
1036    /// durable stream an agent step's transcript is read from, so every reader
1037    /// that already serves transcripts serves this without change.
1038    ///
1039    /// The completion contract is asserted on the same run: the recorded result
1040    /// still carries the command's whole stdout.
1041    #[tokio::test(flavor = "multi_thread")]
1042    async fn a_declared_body_publishes_its_output_onto_the_transcript() -> TestResult {
1043        let (decorated, reached, transcript) = dispatcher_with_root(
1044            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1045                command: "sh -c 'echo one; echo two; echo warned >&2'".to_owned(),
1046            }),
1047            Err("terminal:the worker path must never be reached".to_owned()),
1048            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1049        );
1050        let dispatch = request("noisy", "{}");
1051        let key = ActivityStreamKey::new(
1052            dispatch.workflow_id.clone(),
1053            dispatch.run_id.clone(),
1054            dispatch.activity_id.clone(),
1055            dispatch.attempt,
1056        );
1057
1058        let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1059        let encoded = handle
1060            .await?
1061            .map_err(|error| format!("declared command failed: {error}"))?;
1062
1063        // The replay-authoritative result is untouched by the streaming.
1064        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1065        assert_eq!(outcome["stdout"], "one\ntwo");
1066        assert_eq!(outcome["stderr"], "warned");
1067        assert!(reached_names(&reached).is_empty());
1068
1069        // ...and the same output is on the durable transcript, line by line.
1070        let retained = transcript.replay_from(&key, 0).await?;
1071        let lines = retained
1072            .iter()
1073            .map(|record| match &record.event.kind {
1074                ActivityEventKind::Message { text, .. } => {
1075                    (record.event.agent_role.clone(), text.clone())
1076                }
1077                other => (record.event.agent_role.clone(), format!("{other:?}")),
1078            })
1079            .collect::<Vec<_>>();
1080        assert!(
1081            lines.contains(&("command stdout".to_owned(), "one".to_owned()))
1082                && lines.contains(&("command stdout".to_owned(), "two".to_owned())),
1083            "each stdout line must be its own transcript event: {lines:?}"
1084        );
1085        assert!(
1086            lines.contains(&("command stderr".to_owned(), "warned".to_owned())),
1087            "stderr must be on the transcript, labelled by its stream: {lines:?}"
1088        );
1089        // Sequencing is the publisher's: the durable order is gap-free from 0.
1090        let sequences = retained
1091            .iter()
1092            .map(|record| record.store_seq)
1093            .collect::<Vec<_>>();
1094        assert_eq!(
1095            sequences,
1096            (0..u64::try_from(retained.len())?).collect::<Vec<_>>(),
1097            "the sequencer assigns a gap-free durable order"
1098        );
1099        Ok(())
1100    }
1101
1102    #[tokio::test(flavor = "multi_thread")]
1103    async fn a_failing_declared_command_reports_retryable_with_its_stderr() -> TestResult {
1104        let (decorated, _reached) = dispatcher(
1105            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1106                command: "sh -c 'echo boom >&2; exit 7'".to_owned(),
1107            }),
1108            Ok("unused".to_owned()),
1109        );
1110        let handle =
1111            tokio::task::spawn_blocking(move || decorated.dispatch(request("fails", "{}")));
1112        let Err(error) = handle.await? else {
1113            return Err("a non-zero exit must fail the dispatch".into());
1114        };
1115        assert!(
1116            error.starts_with("retryable:"),
1117            "a non-zero exit is retryable by default: {error}"
1118        );
1119        assert!(
1120            error.contains("boom"),
1121            "stderr must ride the failure: {error}"
1122        );
1123        Ok(())
1124    }
1125
1126    /// The hash the refusal prints must be one the deploy API will accept, or
1127    /// the remedy is a command that cannot run — the exact failure the old
1128    /// "redeploy so one body remains" wording had.
1129    ///
1130    /// The oracle is the deploy API's own parser, not a length or a shape:
1131    /// `EngineDeclaredBodies` renders the version with `ContentHash::to_string`,
1132    /// so this takes a real hash through that rendering, pulls the token back
1133    /// out of the printed command, and parses it the way
1134    /// `decode_version_target` does.
1135    #[test]
1136    fn the_printed_hash_parses_back_as_a_content_hash() -> TestResult {
1137        let version = aion_package::ContentHash::from_bytes([0x5a; 32]);
1138        let routed = aion_package::ContentHash::from_bytes([0xa5; 32]);
1139        let refusal = super::ambiguous_body_refusal(
1140            "find_repositories",
1141            "local",
1142            &[
1143                DeclaringVersion {
1144                    content_hash: version.to_string(),
1145                    workflow_types: vec!["sweeper".to_owned()],
1146                    route_active: false,
1147                    body: 0,
1148                },
1149                DeclaringVersion {
1150                    content_hash: routed.to_string(),
1151                    workflow_types: vec!["sweeper".to_owned()],
1152                    route_active: true,
1153                    body: 1,
1154                },
1155            ],
1156        );
1157        let Some(command) = refusal.split("`aion unload sweeper ").nth(1) else {
1158            return Err(format!("no unload command in the refusal: {refusal}").into());
1159        };
1160        let Some(printed) = command.split('`').next() else {
1161            return Err(format!("the unload command is unterminated: {refusal}").into());
1162        };
1163        let parsed: aion_package::ContentHash = printed.parse()?;
1164        assert_eq!(
1165            parsed, version,
1166            "the printed hash must round-trip to the version it names"
1167        );
1168        Ok(())
1169    }
1170
1171    #[tokio::test(flavor = "multi_thread")]
1172    async fn ambiguous_bodies_refuse_terminally_by_name() -> TestResult {
1173        let superseded = "1111111111111111111111111111111111111111111111111111111111111111";
1174        let routed = "2222222222222222222222222222222222222222222222222222222222222222";
1175        let (decorated, reached) = dispatcher(
1176            DeclaredBodyLookup::Ambiguous {
1177                declaring: vec![
1178                    DeclaringVersion {
1179                        content_hash: superseded.to_owned(),
1180                        workflow_types: vec!["sweeper".to_owned()],
1181                        route_active: false,
1182                        body: 0,
1183                    },
1184                    DeclaringVersion {
1185                        content_hash: routed.to_owned(),
1186                        workflow_types: vec!["sweeper".to_owned()],
1187                        route_active: true,
1188                        body: 1,
1189                    },
1190                ],
1191            },
1192            Ok(String::new()),
1193        );
1194        let handle = tokio::task::spawn_blocking(move || decorated.dispatch(request("torn", "{}")));
1195        let Err(error) = handle.await? else {
1196            return Err("ambiguous bodies must refuse".into());
1197        };
1198        assert!(error.starts_with("terminal:"), "{error}");
1199        assert!(error.contains("torn"), "{error}");
1200        // The refusal must reach the dispatcher carrying an act-on-able remedy,
1201        // not just a count: the operator reads this string and nothing else.
1202        assert!(
1203            error.contains(&format!("`aion unload sweeper {superseded}`")),
1204            "the dispatch refusal must name the version to retire: {error}"
1205        );
1206        assert!(reached_names(&reached).is_empty());
1207        Ok(())
1208    }
1209
1210    #[tokio::test(flavor = "multi_thread")]
1211    async fn a_placeholder_bearing_body_executes_with_the_expanded_root() -> TestResult {
1212        let scratch = tempfile::tempdir()?;
1213        let root = scratch.path().join("clones");
1214        let root_text = root.to_string_lossy().into_owned();
1215        let (decorated, reached, _transcript) = dispatcher_with_root(
1216            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1217                command: "echo {workspace_root}".to_owned(),
1218            }),
1219            Err("terminal:the worker path must never be reached".to_owned()),
1220            WorkspaceRoot::from_resolution(Ok(root.clone())),
1221        );
1222        let handle =
1223            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1224        let result = handle.await?;
1225        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1226        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1227        assert_eq!(
1228            outcome["stdout"], root_text,
1229            "the command must observe the server-resolved root as its argv word"
1230        );
1231        assert_eq!(outcome["exit_code"], 0);
1232        assert!(
1233            root.is_dir(),
1234            "dispatching a placeholder-bearing body must create the missing root"
1235        );
1236        assert!(reached_names(&reached).is_empty());
1237        Ok(())
1238    }
1239
1240    #[tokio::test(flavor = "multi_thread")]
1241    async fn a_placeholder_bearing_body_refuses_terminally_when_the_root_is_unresolved()
1242    -> TestResult {
1243        let (decorated, reached, _transcript) = dispatcher_with_root(
1244            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1245                command: "echo {workspace_root}".to_owned(),
1246            }),
1247            Ok("unused".to_owned()),
1248            WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1249                reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1250            })),
1251        );
1252        let handle =
1253            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1254        let Err(error) = handle.await? else {
1255            return Err("an unresolved root must refuse a placeholder-bearing body".into());
1256        };
1257        assert!(error.starts_with("terminal:"), "{error}");
1258        assert!(
1259            error.contains("provision"),
1260            "the refusal must name the action: {error}"
1261        );
1262        assert!(
1263            error.contains("cannot resolve Aion home"),
1264            "the refusal must carry the resolution failure's reason: {error}"
1265        );
1266        assert!(
1267            reached_names(&reached).is_empty(),
1268            "a refused body must not fall through to the worker path"
1269        );
1270        Ok(())
1271    }
1272
1273    #[tokio::test(flavor = "multi_thread")]
1274    async fn a_shape_changing_root_refuses_terminally_naming_the_action() -> TestResult {
1275        // A `{` in the root would pair with the `{` the command continues
1276        // with, opening a `{{` interpolation neither of them wrote. `$` used
1277        // to sit here and no longer can: it opens nothing now, so a root
1278        // containing one is an ordinary path.
1279        let (decorated, reached, _transcript) = dispatcher_with_root(
1280            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1281                command: "echo {workspace_root}".to_owned(),
1282            }),
1283            Ok("unused".to_owned()),
1284            WorkspaceRoot::from_resolution(Ok(std::path::PathBuf::from("/absolute/with{brace"))),
1285        );
1286        let handle =
1287            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1288        let Err(error) = handle.await? else {
1289            return Err("a shape-changing root must refuse a placeholder-bearing body".into());
1290        };
1291        assert!(error.starts_with("terminal:"), "{error}");
1292        assert!(
1293            error.contains("provision"),
1294            "the refusal must name the action: {error}"
1295        );
1296        assert!(
1297            error.contains("would change the parsed shape"),
1298            "the refusal must carry the shape-changing diagnosis: {error}"
1299        );
1300        assert!(
1301            reached_names(&reached).is_empty(),
1302            "a refused body must not fall through to the worker path"
1303        );
1304        Ok(())
1305    }
1306
1307    #[tokio::test(flavor = "multi_thread")]
1308    async fn an_uncreatable_root_refuses_terminally_naming_the_action() -> TestResult {
1309        // A root beneath a regular file cannot be created by any retry.
1310        let scratch = tempfile::tempdir()?;
1311        let file = scratch.path().join("occupied");
1312        std::fs::write(&file, b"not a directory")?;
1313        let (decorated, reached, _transcript) = dispatcher_with_root(
1314            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1315                command: "echo {workspace_root}".to_owned(),
1316            }),
1317            Ok("unused".to_owned()),
1318            WorkspaceRoot::from_resolution(Ok(file.join("clones"))),
1319        );
1320        let handle =
1321            tokio::task::spawn_blocking(move || decorated.dispatch(request("provision", "{}")));
1322        let Err(error) = handle.await? else {
1323            return Err("an uncreatable root must refuse a placeholder-bearing body".into());
1324        };
1325        assert!(error.starts_with("terminal:"), "{error}");
1326        assert!(
1327            error.contains("provision"),
1328            "the refusal must name the action: {error}"
1329        );
1330        assert!(
1331            error.contains("could not be created"),
1332            "the refusal must carry the creation-failure diagnosis: {error}"
1333        );
1334        assert!(
1335            reached_names(&reached).is_empty(),
1336            "a refused body must not fall through to the worker path"
1337        );
1338        Ok(())
1339    }
1340
1341    #[tokio::test(flavor = "multi_thread")]
1342    async fn a_body_without_the_placeholder_is_untouched_by_resolution_failure() -> TestResult {
1343        let (decorated, _reached, _transcript) = dispatcher_with_root(
1344            DeclaredBodyLookup::Declared(ActionBodyContract::Run {
1345                command: "echo {{greeting}}".to_owned(),
1346            }),
1347            Ok("unused".to_owned()),
1348            WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
1349                reason: "cannot resolve Aion home: set AION_HOME or HOME".to_owned(),
1350            })),
1351        );
1352        let handle = tokio::task::spawn_blocking(move || {
1353            decorated.dispatch(request("greet", "{\"greeting\":\"still served\"}"))
1354        });
1355        let result = handle.await?;
1356        let encoded = result.map_err(|error| format!("declared command failed: {error}"))?;
1357        let outcome: serde_json::Value = serde_json::from_str(&encoded)?;
1358        assert_eq!(outcome["stdout"], "still served");
1359        Ok(())
1360    }
1361
1362    #[tokio::test(flavor = "multi_thread")]
1363    async fn an_unreadable_catalog_delegates_to_the_worker_path() -> TestResult {
1364        let (decorated, reached) = dispatcher(
1365            DeclaredBodyLookup::Unreadable("catalog offline".to_owned()),
1366            Ok("\"served anyway\"".to_owned()),
1367        );
1368        let handle =
1369            tokio::task::spawn_blocking(move || decorated.dispatch(request("resilient", "{}")));
1370        let result = handle.await?;
1371        assert_eq!(result, Ok("\"served anyway\"".to_owned()));
1372        assert_eq!(reached_names(&reached), vec!["resilient".to_owned()]);
1373        Ok(())
1374    }
1375
1376    #[test]
1377    fn non_object_input_is_refused_terminally_by_shape() {
1378        for (input, kind) in [
1379            ("[1,2]", "an array"),
1380            ("\"text\"", "a string"),
1381            ("3", "a number"),
1382            ("null", "null"),
1383            ("true", "a boolean"),
1384        ] {
1385            let Err(error) = decode_arguments(input) else {
1386                unreachable_refusal(input);
1387                return;
1388            };
1389            assert!(error.starts_with("terminal:"), "{error}");
1390            assert!(error.contains(kind), "{error} must name {kind}");
1391        }
1392    }
1393
1394    /// Fails the calling test without a panicking accessor.
1395    fn unreachable_refusal(input: &str) {
1396        assert!(
1397            input.is_empty(),
1398            "input `{input}` must have been refused by shape"
1399        );
1400    }
1401
1402    /// The selection rule cannot be right if it is asked about the wrong run.
1403    ///
1404    /// `select_declared_body` is unit-tested on inputs the test itself
1405    /// constructs, which proves the rule and nothing about the plumbing. This
1406    /// asserts the other half: the identity the dispatcher hands the reader is
1407    /// the identity of the dispatch it is serving, not a placeholder and not
1408    /// another run's.
1409    #[tokio::test(flavor = "multi_thread")]
1410    async fn the_reader_is_asked_about_the_run_that_is_dispatching() -> TestResult {
1411        let seen = Arc::new(Mutex::new(Vec::new()));
1412        let bodies = DeclaredBodySource::default();
1413        bodies.install(Arc::new(RecordingBodies {
1414            seen: Arc::clone(&seen),
1415        }));
1416        let reached = Arc::new(Mutex::new(Vec::new()));
1417        let decorated = DeclaredCommandDispatcher::new(
1418            Arc::new(RecordingInner {
1419                reached: Arc::clone(&reached),
1420                reply: Ok("\"worker-served\"".to_owned()),
1421            }),
1422            bodies,
1423            DeclaredCommandAttempts::new(crate::shutdown::DrainState::default()),
1424            tokio::runtime::Handle::current(),
1425            WorkspaceRoot::from_resolution(Ok(std::env::temp_dir())),
1426            ActivityEventPublisher::new(
1427                Arc::new(aion_store::InMemoryObservabilityStore::default()),
1428                TRANSCRIPT_CAPACITY,
1429                crate::activity_publisher::TranscriptBatchPolicy::UNBATCHED,
1430            ),
1431        );
1432
1433        let dispatch = request("plain", "{}");
1434        let expected = (dispatch.workflow_id.clone(), dispatch.run_id.clone());
1435        let handle = tokio::task::spawn_blocking(move || decorated.dispatch(dispatch));
1436        handle
1437            .await?
1438            .map_err(|error| format!("dispatch failed: {error}"))?;
1439
1440        let observed = match seen.lock() {
1441            Ok(observed) => observed.clone(),
1442            Err(poisoned) => poisoned.into_inner().clone(),
1443        };
1444        assert_eq!(
1445            observed,
1446            vec![expected],
1447            "the body reader must be asked about the dispatching run itself"
1448        );
1449        Ok(())
1450    }
1451}