Skip to main content

mlua_swarm/
operator.rs

1//! Operator abstraction.
2//!
3//! ## Roles
4//!
5//! - **Spawners** (`SpawnerAdapter`) do not know about `Operator` `kind`s.
6//!   Ordinary dispatches are handled by `ProcessSpawner` /
7//!   `InProcSpawner` / etc.
8//! - `OperatorSpawner` is the `SpawnerAdapter` that routes dispatches
9//!   through an operator. It holds an `Arc<dyn Operator>` and does one
10//!   thing: hand every spawn request to that operator's `execute`. It
11//!   still does not know the operator's `kind` (`MainAi` / `Human` /
12//!   `Automate` / `Composite`).
13//! - The `Operator` trait itself returns a `WorkerResult`, as a
14//!   synchronous backend. Implementations are free per kind — a `MainAi`
15//!   operator might round-trip through Claude via an HTTP callback, a
16//!   `Human` operator might prompt on a CLI, an `Automate` operator
17//!   might delegate to a different spawner, and so on.
18//!
19//! Which dispatches go through the `OperatorSpawner` is decided at the
20//! flow.ir layer (designer + hints + Swarm compiler). The algocline
21//! strategy side never says "hand this to the operator" — a firm
22//! separation of concerns.
23
24pub mod render;
25
26pub use render::{render_system, slots_from_prompt, RenderError};
27
28use crate::core::ctx::Ctx;
29use crate::core::engine::Engine;
30use crate::types::{CapToken, StepId, WorkerId};
31use crate::worker::adapter::{SpawnError, SpawnerAdapter, WorkerError, WorkerResult};
32use crate::worker::output::{ContentRef, OutputEvent};
33use crate::worker::{Worker, WorkerJoinHandler};
34use async_trait::async_trait;
35use serde_json::Value;
36use std::sync::Arc;
37use tokio::sync::oneshot;
38use tokio_util::sync::CancellationToken;
39
40/// Worker binding baked from `AgentDef.profile` at compile time — which
41/// worker variant the operator backend must run, plus the tool surface
42/// the Blueprint declared for this agent.
43///
44/// `variant` is mse domain vocabulary; backend-specific terms (e.g. the
45/// Claude Code Agent tool's `subagent_type` parameter) belong to the
46/// rendering boundary (`operator_ws::session` directive render), not here.
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
48pub struct WorkerBinding {
49    /// Worker variant name (for the Claude Code backend this maps onto
50    /// the Agent tool `subagent_type` at directive-render time).
51    #[serde(alias = "subagent_type")]
52    pub variant: String,
53    /// Tool list declared in `AgentDef.profile.tools` (informational
54    /// for the MainAI / observability; the SubAgent's own frontmatter
55    /// is what actually grants tools).
56    pub tools: Vec<String>,
57    /// Digest of the immutable declaration-only `BoundAgent` snapshot this
58    /// binding was resolved from (`sha256:<hex>`). Carried into the spawn
59    /// frame so a non-strict Operator can correlate the request and self-check
60    /// its own environment against it. Like `tools`, this is informational —
61    /// a self-check input for the Operator, not a Server-enforced gate. `None`
62    /// on construction sites that have no snapshot (compile-time / test paths).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub request_digest: Option<crate::blueprint::BindingDigest>,
65    /// Model name or tier declared in `AgentDef.profile.model`, forwarded so
66    /// the Operator can compare the requested model against what its
67    /// environment actually runs. Informational (self-check input), not an
68    /// enforcement field. `None` when the profile declares no model.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub requested_model: Option<String>,
71}
72
73/// The `Operator` trait: takes a spawn request and returns a
74/// `WorkerResult`. The backend for `OperatorSpawner`. Implementations
75/// are free to differ per kind; the spawner just calls `execute` and
76/// stays out of the internals.
77///
78/// Arguments — a two-slot payload plus `worker_token` (the thin path
79/// was added later) plus `worker` (the Blueprint-baked binding, added
80/// later still):
81///
82/// - `system`: the agent persona — the rendered value of
83///   `AgentDef.profile.system_prompt` after template expansion. `None`
84///   means no profile. Expected to map straight onto the LLM API's
85///   system message; direct-LLM operators consume this.
86/// - `prompt`: task-specific intent — `TaskSpec.initial_directive`,
87///   pulled server-side via `engine.fetch_prompt`. Expected to map
88///   straight onto the LLM API's user message.
89/// - `worker`: the compile-time-baked [`WorkerBinding`] (subagent type +
90///   declared tools) resolved from `AgentDef.profile.worker_binding`.
91///   `None` for agents whose profile has no `worker_binding` set.
92///   Backends that require one (see [`Operator::requires_worker_binding`])
93///   must fail loud rather than silently degrade when this is `None`.
94/// - `worker_token`: a capability token (`Role::Worker`,
95///   `scopes = ["*"]`, TTL from
96///   [`EngineCfg::worker_token_ttl_secs`](crate::EngineCfg) — default
97///   1800s). Thin-path operators (a `a WebSocket-backed operator session`,
98///   for instance) `encode()` this token and hand it to the MainAI
99///   WebSocket client, so the SubAgent can hit `/v1/worker/prompt` +
100///   `/v1/worker/result` with `Authorization: Bearer <encoded>`.
101///   Direct-LLM operators may ignore it.
102///
103/// The trait passes both slots so the same signature works for the
104/// thin path and the direct path; the implementation picks which one
105/// it takes (consume the server-rendered `system` directly, or forward
106/// the token and let the client fetch).
107#[async_trait]
108pub trait Operator: Send + Sync {
109    /// Executes one spawn request against this operator's backend and
110    /// returns the resulting `WorkerResult` (or a `WorkerError` if the
111    /// backend failed). See the trait doc above for the meaning of each
112    /// argument.
113    async fn execute(
114        &self,
115        ctx: &Ctx,
116        system: Option<String>,
117        prompt: Value,
118        worker: Option<WorkerBinding>,
119        worker_token: CapToken,
120    ) -> Result<WorkerResult, WorkerError>;
121
122    /// Whether this operator backend requires a non-`None` `worker`
123    /// binding to execute at all. `false` by default (direct-LLM
124    /// operators consume `system` / `prompt` directly and have no
125    /// SubAgent to dispatch). WS thin-path operators override this to
126    /// `true` — the compiler uses it to fail loud at `compile()` time
127    /// when `AgentDef.profile.worker_binding` is absent, rather than
128    /// silently degrading at dispatch time.
129    fn requires_worker_binding(&self) -> bool {
130        false
131    }
132}
133
134/// Resolves the `Arc<dyn Operator>` a Blueprint-declared Operator seat
135/// dispatches through.
136///
137/// # Why a hook instead of a lookup
138///
139/// `AgentDef.spec.operator_ref` names a **seat** (one of
140/// `Blueprint.operators[]`), not a backend. Historically
141/// [`OperatorSpawnerFactory`](crate::OperatorSpawnerFactory) answered it by
142/// looking the name up in its own `id → Arc<dyn Operator>` map, which baked
143/// whichever session held that name at compile time into
144/// `routes[agent_name]` for the whole Run — so re-assigning the seat later
145/// could not change where a dispatch went (model §4.3 **A10**: *the
146/// destination is not baked in*).
147///
148/// A host that records seat holders per Run installs a resolver instead. It
149/// is handed the seat name and returns the indirection that performs the
150/// per-dispatch holder lookup (`mlua-swarm-server`'s `AssigneeRouter`), so
151/// what gets baked is **which seat**, never **who holds it**.
152///
153/// The hook lives here rather than in the compiler because the resolving
154/// type needs a `RunStore` and the live session registry, both of which are
155/// the host's; the core only needs to know that something can answer
156/// "operator for seat *X*".
157pub trait OperatorSlotResolver: Send + Sync {
158    /// The backend for `slot`, or `None` when this resolver cannot serve
159    /// that seat — which fails the compile loudly (there is deliberately no
160    /// fallback to the factory's own registry, since falling back is how a
161    /// dispatch ends up somewhere the caller never named).
162    fn resolve(&self, slot: &str) -> Option<Arc<dyn Operator>>;
163}
164
165/// A `SpawnerAdapter` implementation that hands the dispatch off to an
166/// `Arc<dyn Operator>`.
167///
168/// `OperatorSpawner` itself does not inspect the operator's `kind` —
169/// `MainAi` / `Human` / `Automate` / `Composite` all go through the same
170/// path, and the operator implementation absorbs the differences.
171///
172/// # Position — the AgentSpec-axis Operator path
173///
174/// Use this type on the path that **bakes a separate Operator backend
175/// into every `AgentDef`**. For an `AgentKind::Operator` `AgentDef`, the
176/// `OperatorSpawnerFactory` produces one with
177/// `OperatorSpawner::new(op, system_prompt, worker_binding)` and places it
178/// in `routes[agent_name]`. Agents flowing in through the `agent.md`
179/// loader default to `kind = Operator`, so they land here.
180///
181/// This is now the **only** way a dispatch reaches an `Operator`. There
182/// used to be a paired Blueprint-global (session) axis,
183/// `crate::middleware::OperatorDelegateMiddleware`, which registered one
184/// backend on the session and applied it uniformly to every agent; when
185/// both were effective it sat at the outer end of the stack and bypassed
186/// `inner.spawn`, leaving this type inert. It was removed — it resolved
187/// its destination from the launch record rather than the Run's seat (so
188/// a handover could not move it) and, having no per-agent spawner, could
189/// not render or bake an agent's `system_prompt`. With one axis left,
190/// the exclusivity question it created goes away with it.
191pub struct OperatorSpawner {
192    operator: Arc<dyn Operator>,
193    /// The compile-time-baked `AgentDef.profile.system_prompt` — the
194    /// agent's persona. If `Some`, it takes priority at spawn time; if
195    /// `None`, we fall back to `fetch_prompt` (`initial_directive`).
196    system_prompt: Option<String>,
197    /// The compile-time-baked worker binding — resolved from
198    /// `AgentDef.profile.worker_binding` by `OperatorSpawnerFactory`.
199    /// Passed straight through to `Operator::execute` on every spawn.
200    worker_binding: Option<WorkerBinding>,
201}
202
203impl OperatorSpawner {
204    /// Binds an operator backend plus an optional compile-time
205    /// `system_prompt` template (rendered per-spawn via `render_system`)
206    /// and an optional compile-time-baked `worker_binding`.
207    pub fn new(
208        operator: Arc<dyn Operator>,
209        system_prompt: Option<String>,
210        worker_binding: Option<WorkerBinding>,
211    ) -> Self {
212        Self {
213            operator,
214            system_prompt,
215            worker_binding,
216        }
217    }
218}
219
220#[async_trait]
221impl SpawnerAdapter for OperatorSpawner {
222    async fn spawn(
223        &self,
224        engine: &Engine,
225        ctx: &Ctx,
226        task_id: StepId,
227        attempt: u32,
228        token: CapToken,
229    ) -> Result<Box<dyn Worker>, SpawnError> {
230        // By convention the spawner pulls `prompt`
231        // through `fetch_prompt`. The `system_prompt` (from
232        // `AgentDef.profile`) travels on the other slot — sibling to the
233        // AgentBlock path's `BlockConfig.context` / `.prompt` split.
234        let prompt = engine
235            .fetch_prompt(&token, &task_id)
236            .await
237            .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
238
239        // Render the `system_prompt` template.
240        // Expand the prompt into a slot map and hand the template to
241        // minijinja. The syntax used inside the agent.md body is
242        // Jinja2-compatible (`{{ directive }}` / `{% if intent %}` /
243        // `{{ x | upper }}`), with strict undefined variables and
244        // auto-escape disabled.
245        let system = match self.system_prompt.as_deref() {
246            Some(tmpl) => {
247                let slots = render::slots_from_prompt(&prompt);
248                let rendered = render::render_system(tmpl, &slots)
249                    .map_err(|e| SpawnError::Internal(format!("render system_prompt: {e}")))?;
250                Some(rendered)
251            }
252            None => None,
253        };
254
255        // Bake the rendered `system`
256        // into engine state so the SubAgent can fetch it alongside
257        // `prompt` on the `HTTP /v1/worker/prompt` path. Failures are
258        // fail-loud via `SpawnError::Internal` — no silent fallback.
259        engine
260            .bake_worker_system_prompt(&task_id, attempt, system.clone())
261            .await
262            .map_err(|e| SpawnError::Internal(format!("bake system_prompt: {e}")))?;
263
264        let op = self.operator.clone();
265        let engine_clone = engine.clone();
266        let token_clone = token.clone();
267        let token_for_op = token.clone();
268        let task_id_clone = task_id.clone();
269        let ctx_clone = ctx.clone();
270        let worker_binding = self.worker_binding.clone();
271        let (tx, rx) = oneshot::channel();
272        let cancel = CancellationToken::new();
273        let cancel_inner = cancel.clone();
274        let worker_id = WorkerId::new();
275        // issue #11: surface the minted WorkerId in the trace log.
276        tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (operator spawner)");
277
278        tokio::spawn(async move {
279            let result: Result<WorkerResult, WorkerError> = tokio::select! {
280                r = op.execute(&ctx_clone, system, prompt, worker_binding, token_for_op) => r,
281                _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
282            };
283            // Per-step run stats: the WS operator ack may attach
284            // harness-reported SubAgent usage — forward it to the
285            // engine so the dispatcher's outcome fold lands it on the
286            // terminal StepEntry. Even without stats attached,
287            // `ensure_worker_kind` guarantees the `worker_kind:
288            // "operator"` label always rides (same funnel as the InProc /
289            // subprocess fold sites).
290            let result = result.map(|wr| wr.ensure_worker_kind("operator"));
291            if let Ok(wr) = &result {
292                if let Some(stats) = wr.stats.clone() {
293                    engine_clone
294                        .record_worker_stats(&task_id_clone, attempt, stats)
295                        .await;
296                }
297            }
298            // Emit `WorkerResult` → `OutputEvent::Final` in
299            // parallel. If the SubAgent already
300            // pushed a `Final` via HTTP (`/v1/worker/result` or
301            // `/v1/worker/submit`), skip. The POSTed value is canonical
302            // — protocol.rs L107-110 design intent. Only operator
303            // implementations that do not POST (tests, inline
304            // operators) need this fallback emit.
305            if let Ok(wr) = &result {
306                let tail = engine_clone.output_tail(&task_id_clone, attempt).await;
307                let has_final = tail
308                    .iter()
309                    .any(|ev| matches!(ev, OutputEvent::Final { .. }));
310                if !has_final {
311                    let ev = OutputEvent::Final {
312                        content: ContentRef::Inline {
313                            value: wr.value.clone(),
314                        },
315                        ok: wr.ok,
316                    };
317                    // The capability this closure captured was minted
318                    // before `execute` was called, and `execute` is where
319                    // the wait lives: the WS backend parks a spawn frame
320                    // for the length of a client disconnect with no
321                    // deadline, while the token counts down
322                    // `EngineCfg::worker_token_ttl_secs`. Past that,
323                    // `submit_output`'s `verify_token_for_task` rejects it
324                    // with `TokenExpired` — and because this emit is the
325                    // fallback for a SubAgent that never POSTed a `Final`
326                    // of its own, nothing else would write one: the
327                    // attempt's whole result would be lost to the clock,
328                    // not to anything about the work. Re-mint against the
329                    // record the engine already holds, which cannot widen
330                    // the grant (same subject / role / scopes / bound
331                    // task; only `expire_at` moves — see
332                    // `Engine::remint_worker_token`).
333                    //
334                    // The un-lapsed case is left alone deliberately rather
335                    // than re-minting unconditionally: every operator
336                    // dispatch reaches this line, and `remint` leaves the
337                    // old record in place by design, so minting on each
338                    // one would grow `EngineState.tokens` by a spare entry
339                    // per step for no gain.
340                    let submit_token = if token_clone.is_expired(crate::types::now_unix()) {
341                        match engine_clone.remint_worker_token(&token_clone).await {
342                            Ok(fresh) => fresh,
343                            Err(e) => {
344                                // Nothing here is retryable with the token
345                                // in hand — it is already past its TTL, so
346                                // submitting with it is a call known to
347                                // fail. Say what was lost instead.
348                                tracing::error!(
349                                    step_id = %task_id_clone,
350                                    attempt,
351                                    error = %e,
352                                    "operator fallback Final dropped: the worker capability \
353                                     lapsed while the spawn frame was parked and could not be \
354                                     re-minted; this attempt has no Final"
355                                );
356                                let _ = tx.send(result.map(|_| ()));
357                                return;
358                            }
359                        }
360                    } else {
361                        token_clone.clone()
362                    };
363                    // GH #51: `submit_output` embeds the completion-time
364                    // verdict-contract check (see
365                    // `Engine::verdict_contract_completion_check`'s doc)
366                    // — this fallback emit is gated by it exactly like
367                    // the HTTP routes are, with zero new WS protocol
368                    // surface. On rejection the `Final` is simply never
369                    // written: `output_tail` stays without one, and the
370                    // downstream `dispatch_attempt_with` Final-pull
371                    // naturally treats the attempt as incomplete — no new
372                    // reject-back-to-client message is synthesized (the
373                    // deliberate "Zero flow-ir changes" design choice, not
374                    // a gap to fill).
375                    if let Err(e) = engine_clone
376                        .submit_output(&submit_token, &task_id_clone, attempt, ev)
377                        .await
378                    {
379                        // A contract rejection is this gate working, and
380                        // reads as a `warn`. Anything else means the
381                        // `Final` went missing for a reason nobody chose,
382                        // and the old wording — which named the verdict
383                        // gate unconditionally — would have reported a
384                        // lapsed token as a rejected value. Split them so
385                        // the log says which happened.
386                        if matches!(
387                            e,
388                            crate::core::errors::EngineError::VerdictValueRejected { .. }
389                                | crate::core::errors::EngineError::VerdictPartMissing { .. }
390                        ) {
391                            tracing::warn!(
392                                step_id = %task_id_clone,
393                                attempt,
394                                error = %e,
395                                "operator fallback Final rejected by verdict-contract \
396                                 completion gate"
397                            );
398                        } else {
399                            tracing::error!(
400                                step_id = %task_id_clone,
401                                attempt,
402                                error = %e,
403                                "operator fallback Final was not written; this attempt has \
404                                 no Final"
405                            );
406                        }
407                    }
408                }
409            }
410            let signal: Result<(), WorkerError> = result.map(|_| ());
411            let _ = tx.send(signal);
412        });
413
414        Ok(Box::new(OperatorWorker {
415            handler: WorkerJoinHandler {
416                worker_id,
417                cancel,
418                completion: rx,
419            },
420        }))
421    }
422}
423
424/// Concrete Worker type for the Operator kind — wraps the async
425/// `Operator::execute` call. This represents the handle for a task
426/// backed by an operator (SDK, WebSocket bridge, direct LLM call, etc.)
427/// and embeds a `WorkerJoinHandler` that carries the async signal.
428pub struct OperatorWorker {
429    /// The completion-signal handle for this operator call's spawned
430    /// task.
431    pub handler: WorkerJoinHandler,
432}
433
434#[async_trait]
435impl Worker for OperatorWorker {
436    fn id(&self) -> &WorkerId {
437        &self.handler.worker_id
438    }
439    fn cancel_token(&self) -> CancellationToken {
440        self.handler.cancel.clone()
441    }
442    async fn join(self: Box<Self>) -> Result<(), WorkerError> {
443        self.handler.await_completion().await
444    }
445}
446
447// ─── the fallback Final outliving its capability ──────────────────────────
448//
449// `OperatorSpawner::spawn`'s completion path writes a `Final` for the
450// operator whose SubAgent never POSTed one. It does that with the token it
451// was handed at spawn time — and `Operator::execute` is allowed to take
452// arbitrarily long (the WS backend parks a spawn frame across a client
453// disconnect with no deadline), so by the time the fallback runs, that
454// token may be past `EngineCfg::worker_token_ttl_secs`. These tests hold
455// the two halves apart: the parked case must still land its `Final`, and
456// the ordinary case must not start paying for a re-mint it does not need.
457#[cfg(test)]
458mod parked_fallback_capability_tests {
459    use super::*;
460    use crate::core::config::EngineCfg;
461    use crate::core::state::TaskSpec;
462    use crate::types::Role;
463    use crate::worker::adapter::SpawnerAdapter;
464    use std::time::Duration;
465
466    /// The worker-token TTL these tests run against. One second is short
467    /// enough to outlive in a unit test and long enough that the dispatch
468    /// preamble (mint → `fetch_prompt` → spawn) is comfortably inside it,
469    /// so a park is what expires the token and not test scheduling.
470    const TTL_SECS: u64 = 1;
471
472    /// An `Operator` that succeeds without ever writing a `Final` of its
473    /// own — the only shape for which the fallback emit exists — after
474    /// holding for `hold`. `hold` past the TTL reproduces the park.
475    struct SilentOperator {
476        hold: Duration,
477    }
478
479    #[async_trait]
480    impl Operator for SilentOperator {
481        async fn execute(
482            &self,
483            _ctx: &Ctx,
484            _system: Option<String>,
485            _prompt: Value,
486            _worker: Option<WorkerBinding>,
487            _worker_token: CapToken,
488        ) -> Result<WorkerResult, WorkerError> {
489            tokio::time::sleep(self.hold).await;
490            Ok(WorkerResult {
491                value: serde_json::json!({"held": true}),
492                ok: true,
493                stats: None,
494            })
495        }
496    }
497
498    /// Dispatch one attempt through an `OperatorSpawner` wrapping a
499    /// [`SilentOperator`] that holds for `hold`, and hand back the engine
500    /// and the task it ran, for the caller to read state off.
501    async fn dispatch_holding_for(hold: Duration) -> (Engine, StepId) {
502        let engine = Engine::new(EngineCfg {
503            worker_token_ttl_secs: TTL_SECS,
504            ..EngineCfg::default()
505        });
506        let op_token = engine
507            .attach(
508                "op-parked-fallback",
509                Role::Operator,
510                Duration::from_secs(600),
511            )
512            .await
513            .expect("attach");
514        let task_id = engine
515            .start_task(
516                &op_token,
517                TaskSpec {
518                    agent: "held-agent".to_string(),
519                    initial_directive: Value::String("go".to_string()),
520                    step_ctx: None,
521                    check_policy: None,
522                },
523            )
524            .await
525            .expect("start_task");
526        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(OperatorSpawner::new(
527            Arc::new(SilentOperator { hold }),
528            None,
529            None,
530        ));
531        engine
532            .dispatch_attempt_with(&op_token, &task_id, &spawner, None)
533            .await
534            .expect("dispatch_attempt_with");
535        (engine, task_id)
536    }
537
538    /// How many stored capability records bind `task_id` — one after an
539    /// ordinary dispatch, two once the fallback has re-minted (the reissue
540    /// is added and the original deliberately left in place; see
541    /// `Engine::remint_worker_token`). This is the mechanism the test
542    /// below asserts on, as opposed to the outcome alone.
543    async fn records_bound_to(engine: &Engine, task_id: &StepId) -> usize {
544        let wanted = task_id.clone();
545        engine
546            .with_state("test.count_bound_records", move |s| {
547                s.tokens
548                    .values()
549                    .filter(|r| r.task_id.as_ref() == Some(&wanted))
550                    .count()
551            })
552            .await
553            .expect("read token records")
554    }
555
556    fn has_final(tail: &[OutputEvent]) -> bool {
557        tail.iter()
558            .any(|ev| matches!(ev, OutputEvent::Final { .. }))
559    }
560
561    /// The failure this fix is for. A hold past the TTL leaves the spawn
562    /// token expired by the time the fallback writes, `submit_output`'s
563    /// `verify_token_for_task` rejects an expired Worker token, and the
564    /// attempt's only `Final` would be lost to the clock rather than to
565    /// anything about the work — `dispatch_attempt_with` then folds the
566    /// empty tail into "no Final in output_tail". Re-minting at the moment
567    /// of the write is what keeps it.
568    #[tokio::test]
569    async fn a_hold_past_the_ttl_still_lands_the_fallback_final() {
570        let (engine, task_id) =
571            dispatch_holding_for(Duration::from_millis(TTL_SECS * 1000 + 500)).await;
572
573        let tail = engine.output_tail(&task_id, 1).await;
574        assert!(
575            has_final(&tail),
576            "the fallback Final must survive a hold longer than the worker-token TTL, \
577             got tail: {tail:?}"
578        );
579        assert_eq!(
580            records_bound_to(&engine, &task_id).await,
581            2,
582            "the surviving Final must be the re-minted capability's doing — one record \
583             for the spawn token, one for the reissue"
584        );
585    }
586
587    /// Control, and the reason the re-mint is conditional: a dispatch that
588    /// returns inside the TTL writes its `Final` with the token it already
589    /// holds and mints nothing extra. Without this, "always re-mint" would
590    /// pass the test above while adding a spare token record to every
591    /// operator step in the process.
592    #[tokio::test]
593    async fn a_dispatch_inside_the_ttl_lands_its_final_without_re_minting() {
594        let (engine, task_id) = dispatch_holding_for(Duration::from_millis(10)).await;
595
596        let tail = engine.output_tail(&task_id, 1).await;
597        assert!(
598            has_final(&tail),
599            "an un-parked operator's fallback Final must land unchanged, got tail: {tail:?}"
600        );
601        assert_eq!(
602            records_bound_to(&engine, &task_id).await,
603            1,
604            "a live token needs no reissue — only the spawn token should be on record"
605        );
606    }
607}