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