Skip to main content

mlua_swarm/core/
engine.rs

1//! `Engine` — the long-running stateful runtime plus the `with_state`
2//! helper (R1-R4 discipline).
3//!
4//! The engine owns the Domain side of the Data / Domain split:
5//! flow control (dispatch / verdict), state (`EngineState`), and the
6//! `submit_output` / `output_tail` surface that feeds it. Data-plane
7//! traffic (Big Response bodies) is delegated to the `output_store` module
8//! plus its paired `SpawnerLayer`s and passes through here without the
9//! engine core needing to grow.
10
11use crate::core::config::EngineCfg;
12use crate::core::ctx::{Ctx, OperatorInfo, OperatorKind, SeniorBridge, SpawnHook};
13use crate::core::errors::EngineError;
14use crate::core::state::{
15    CapTokenRecord, DispatchOutcome, EngineState, Event, EventStream, OperatorSession, ResumeKey,
16    ResumePending, TaskSpec, TaskState, TaskStatus,
17};
18use crate::types::{
19    default_role_verb_table, now_unix, CapToken, Role, RoleVerbGate, RunId, SessionId, StepId,
20    TokenSigner, Verb,
21};
22use crate::worker::adapter::SpawnerAdapter;
23use serde_json::Value;
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tokio::sync::{broadcast, Mutex};
28
29/// Process-wide long-running runtime. Cheap to `clone()` — an `Arc`
30/// lives inside.
31#[derive(Clone)]
32pub struct Engine {
33    inner: Arc<EngineInner>,
34}
35
36struct EngineInner {
37    state: Mutex<EngineState>,
38    cfg: EngineCfg,
39    signer: TokenSigner,
40    gate: RoleVerbGate,
41    event_tx: broadcast::Sender<Event>,
42    /// ID-keyed bridge registry (register-by-ID design). `SeniorBridge`
43    /// and `SpawnHook` are registered by ID; sessions bind to those IDs
44    /// only. Persistence stores just the ID, and on reattach the caller
45    /// re-registers under the same ID to restore presence.
46    senior_bridges: tokio::sync::RwLock<HashMap<String, Arc<dyn SeniorBridge>>>,
47    spawn_hooks: tokio::sync::RwLock<HashMap<String, Arc<dyn SpawnHook>>>,
48    /// ID registry for full-spawn Operator backends (backends that take the
49    /// entire spawn via `execute`). Sibling to `senior_bridges` /
50    /// `spawn_hooks`. `OperatorDelegateMiddleware` looks these up via
51    /// `ctx` and, when `kind = MainAi` / `Composite`, bypasses
52    /// `inner.spawn` and calls `operator.execute` instead.
53    operators: tokio::sync::RwLock<HashMap<String, Arc<dyn crate::operator::Operator>>>,
54    /// Base and hint layer factories for the `SpawnerStack`. At
55    /// `service::linker::link` time, `compiled.router` is wrapped with
56    /// the base factories plus the hint factories resolved from
57    /// `blueprint.spawner_hints.layers`. This is the engine-side
58    /// counterpart to the discipline "Flow / Blueprint doesn't spell out
59    /// middleware implementations — it declares the capabilities it needs
60    /// as hint keys".
61    layer_registry: crate::middleware::LayerRegistry,
62}
63
64impl Engine {
65    /// Backwards-compatible constructor that starts the engine without a
66    /// layer registry, preserving the signature already used by ~88
67    /// existing call sites. Use this when automatic middleware wrapping
68    /// at bind time is not needed. Callers such as `mlua-swarm-server` go through
69    /// `new_with_layers(cfg, registry)` to enable the hint-resolution path.
70    pub fn new(cfg: EngineCfg) -> Self {
71        Self::new_with_layers(cfg, crate::middleware::LayerRegistry::new())
72    }
73
74    /// Construct an `Engine` with an explicit `LayerRegistry`, enabling
75    /// hint-resolution: `spawner_hints.layers` declared on a `Blueprint`
76    /// are resolved against this registry when the spawner stack is bound
77    /// at `service::linker::link` time.
78    pub fn new_with_layers(
79        cfg: EngineCfg,
80        layer_registry: crate::middleware::LayerRegistry,
81    ) -> Self {
82        let (event_tx, _) = broadcast::channel(256);
83        let signer = TokenSigner::new(&cfg.token_secret);
84        Self {
85            inner: Arc::new(EngineInner {
86                state: Mutex::new(EngineState::new()),
87                cfg,
88                signer,
89                gate: default_role_verb_table(),
90                event_tx,
91                senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
92                spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
93                operators: tokio::sync::RwLock::new(HashMap::new()),
94                layer_registry,
95            }),
96        }
97    }
98
99    /// Rebuild this `Engine` with a different `RoleVerbGate`. The gate is
100    /// treated as fixed-at-build-time, so this constructs a fresh
101    /// `EngineInner` (fresh empty `EngineState`) rather than mutating in
102    /// place — mainly a testing convenience for swapping gate rules.
103    pub fn with_gate(self, gate: RoleVerbGate) -> Self {
104        // The gate is fixed at build time — the intent is to build a fresh
105        // instance rather than mutating in place. As a testing convenience we
106        // do allow swapping the inner Arc. Simpler form: just rebuild
107        // Arc<EngineInner>.
108        let inner = Arc::new(EngineInner {
109            state: Mutex::new(EngineState::new()),
110            cfg: self.inner.cfg.clone(),
111            signer: self.inner.signer.clone(),
112            gate,
113            event_tx: self.inner.event_tx.clone(),
114            senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
115            spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
116            operators: tokio::sync::RwLock::new(HashMap::new()),
117            layer_registry: self.inner.layer_registry.clone(),
118        });
119        Self { inner }
120    }
121
122    // ═══════════════════════════════════════════════════════════════════════
123    // Accessors. Production code drives execution through compile +
124    // `service::linker::link` + `dispatch_attempt_with(spawner)` inside
125    // `TaskLaunchService`; `Engine` itself is a pure execution surface — it
126    // does not own a BlueprintStore / EnhanceAdapter / Compiler, nor a
127    // global spawner (the spawner is carried per-request, never stashed on
128    // the engine).
129    // ═══════════════════════════════════════════════════════════════════════
130
131    /// Access the `EngineCfg` this engine was built with.
132    pub fn cfg(&self) -> &EngineCfg {
133        &self.inner.cfg
134    }
135
136    /// Expose the internal `LayerRegistry` — used when deriving a
137    /// sub-engine that needs the same registry re-injected. The
138    /// per-request sub-engine in `mlua-swarm-server` reads the parent engine's
139    /// registry through this accessor and passes it to
140    /// `Engine::new_with_layers(cfg, parent.layer_registry().clone())`.
141    pub fn layer_registry(&self) -> &crate::middleware::LayerRegistry {
142        &self.inner.layer_registry
143    }
144
145    /// Access the `TokenSigner` used to mint/verify `CapToken`s.
146    pub fn signer(&self) -> &TokenSigner {
147        &self.inner.signer
148    }
149
150    /// Clone a handle to the process-wide `Event` broadcast sender. Prefer
151    /// `subscribe` for a ready-to-use receiver.
152    pub fn event_tx(&self) -> broadcast::Sender<Event> {
153        self.inner.event_tx.clone()
154    }
155
156    /// Subscribe to the engine's `Event` broadcast stream.
157    pub fn subscribe(&self) -> EventStream {
158        self.inner.event_tx.subscribe()
159    }
160
161    // ═══════════════════════════════════════════════════════════════════════
162    // §7 with_state — single Mutex + R1-R4 (try_lock + bounded retry + max-hold panic)
163    // ═══════════════════════════════════════════════════════════════════════
164
165    /// The closure is a **sync** `FnOnce` — you cannot pass an async
166    /// closure, which enforces R3 at the type level. Exceeding `max_hold`
167    /// panics so that R4 violations surface immediately.
168    pub async fn with_state<F, R>(&self, op: &'static str, f: F) -> Result<R, EngineError>
169    where
170        F: FnOnce(&mut EngineState) -> R,
171    {
172        let cfg = &self.inner.cfg;
173
174        // R2: try_lock + bounded retry
175        let mut guard_opt = None;
176        for attempt in 0..=cfg.max_retry {
177            match self.inner.state.try_lock() {
178                Ok(g) => {
179                    guard_opt = Some(g);
180                    break;
181                }
182                Err(_) if cfg.try_only => return Err(EngineError::LockBusy(op)),
183                Err(_) => {
184                    let backoff = cfg.backoff_ms_step * (attempt as u64 + 1);
185                    tokio::time::sleep(Duration::from_millis(backoff)).await;
186                }
187            }
188        }
189        let mut guard = guard_opt.ok_or(EngineError::LockBusyAfterRetry(op))?;
190
191        // R4: max_hold guard
192        let start = Instant::now();
193        let result = f(&mut guard);
194        let elapsed_ms = start.elapsed().as_millis();
195        drop(guard);
196
197        if elapsed_ms > cfg.max_hold_ms {
198            panic!(
199                "Engine.with_state('{op}') held {elapsed_ms}ms > max {}ms — suspected R3 violation (long op inside lock)",
200                cfg.max_hold_ms
201            );
202        }
203        Ok(result)
204    }
205
206    // ═══════════════════════════════════════════════════════════════════════
207    // Token verify (= sig + expire + gate + uses_left)
208    // ═══════════════════════════════════════════════════════════════════════
209
210    /// Four steps: (1) signature verify, (2) expiry check, (3) role × verb
211    /// gate, (4) `uses_left` consume.
212    pub async fn verify_token(&self, token: &CapToken, verb: Verb) -> Result<(), EngineError> {
213        // (1) sig
214        if !self.inner.signer.verify_sig(token) {
215            return Err(EngineError::BadSignature);
216        }
217        // (2) expire
218        if token.is_expired(now_unix()) {
219            return Err(EngineError::TokenExpired);
220        }
221        // (3) role × verb gate
222        if !self.inner.gate.is_allowed(token.role, verb) {
223            return Err(EngineError::RoleViolation {
224                role: token.role,
225                verb,
226            });
227        }
228        // (4) server-side uses_left consume
229        let fp = token.fingerprint();
230        self.with_state("token.consume", move |s| {
231            let rec = s
232                .tokens
233                .get_mut(&fp)
234                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))?;
235            rec.consume()
236                .map_err(|_: crate::core::state::CapTokenConsumeError| {
237                    EngineError::TokenUsesExhausted
238                })?;
239            Ok::<(), EngineError>(())
240        })
241        .await??;
242        Ok(())
243    }
244
245    /// `verify_token` plus the **task-ownership gate**.
246    ///
247    /// When a Worker-role token calls a state-touch verb (`fetch_prompt` /
248    /// `post_result` / `read_task_state` / `cancel_task` / `poll_task`),
249    /// the gate checks that `CapTokenRecord.task_id` matches the argument
250    /// `task_id`; a mismatch returns `EngineError::TokenTaskMismatch`.
251    /// Operator / Senior / Observer tokens are outside the ownership gate
252    /// and may touch any task.
253    ///
254    /// **Verbs exempt from the gate.** `start_task` and `dispatch_attempt`
255    /// stay outside so recursive swarming keeps working; depth is capped
256    /// by `max_spawn_depth`.
257    pub async fn verify_token_for_task(
258        &self,
259        token: &CapToken,
260        verb: Verb,
261        task_id: &StepId,
262    ) -> Result<(), EngineError> {
263        self.verify_token(token, verb).await?;
264        if token.role != Role::Worker {
265            return Ok(());
266        }
267        let fp = token.fingerprint();
268        let arg_tid = task_id.clone();
269        self.with_state("token.ownership_gate", move |s| {
270            let bound = s.tokens.get(&fp).and_then(|r| r.task_id.as_ref()).cloned();
271            match bound {
272                Some(t) if t == arg_tid => Ok(()),
273                Some(t) => Err(EngineError::TokenTaskMismatch {
274                    bound: t.into_string(),
275                    arg: arg_tid.into_string(),
276                }),
277                None => Err(EngineError::TokenNotFound(fp.clone())),
278            }
279        })
280        .await??;
281        Ok(())
282    }
283
284    /// Resolve the bound `task_id` from a Worker-role token. Used on the
285    /// simple `/v1/worker/submit` endpoint, where the worker POSTs with a
286    /// token but no `task_id`. Returns `Err` if the token role is not
287    /// Worker, or if no bound task is set.
288    pub async fn task_id_from_token(&self, token: &CapToken) -> Result<StepId, EngineError> {
289        if token.role != Role::Worker {
290            return Err(EngineError::RoleViolation {
291                role: token.role,
292                verb: Verb::PostResult,
293            });
294        }
295        let fp = token.fingerprint();
296        self.with_state("task_id_from_token", move |s| {
297            s.tokens
298                .get(&fp)
299                .and_then(|r| r.task_id.as_ref())
300                .cloned()
301                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))
302        })
303        .await?
304    }
305
306    /// Resolve a short worker handle (`wh-XXXXXXXX`) to the bound
307    /// `task_id`. Used on `/v1/worker/submit` when the Bearer is a short
308    /// handle string rather than a full `CapToken` JSON. A missing entry
309    /// returns `TokenNotFound`, i.e. "the handle is not in the store".
310    pub async fn task_id_from_handle(&self, handle: &str) -> Result<StepId, EngineError> {
311        let h = handle.to_string();
312        self.with_state("task_id_from_handle", move |s| {
313            let fp = s
314                .worker_handles
315                .get(&h)
316                .cloned()
317                .ok_or_else(|| EngineError::TokenNotFound(format!("handle={h}")))?;
318            s.tokens
319                .get(&fp)
320                .and_then(|r| r.task_id.as_ref())
321                .cloned()
322                .ok_or_else(|| EngineError::TokenNotFound(format!("fp={fp}")))
323        })
324        .await?
325    }
326
327    /// Submit a worker result via a short handle. Skips token verification
328    /// and updates `output_tail` `Final` + `task.last_result` directly in
329    /// a thin path. The caller is expected to have already resolved
330    /// `task_id` via `task_id_from_handle` — the handle's presence in
331    /// `worker_handles` means it was minted server-side and is therefore
332    /// trusted.
333    pub async fn submit_worker_result_trusted(
334        &self,
335        task_id: &StepId,
336        attempt: u32,
337        value: Value,
338        ok: bool,
339    ) -> Result<(), EngineError> {
340        let task_id_for_apply = task_id.clone();
341        let value_for_event = value.clone();
342        self.with_state("submit_worker_result_trusted.output", move |s| {
343            let ev = crate::worker::output::OutputEvent::Final {
344                content: crate::worker::output::ContentRef::Inline {
345                    value: value_for_event,
346                },
347                ok,
348            };
349            s.output_store
350                .entry((task_id_for_apply.clone(), attempt))
351                .or_default()
352                .push(ev.clone());
353            s.push_event(crate::core::state::Event::WorkerOutput {
354                task_id: task_id_for_apply,
355                attempt,
356                event: ev,
357            });
358        })
359        .await?;
360        let task_id_for_result = task_id.clone();
361        let value_for_result = value.clone();
362        self.with_state("submit_worker_result_trusted.last_result", move |s| {
363            if let Some(t) = s.tasks.get_mut(&task_id_for_result) {
364                t.last_result = Some(value_for_result);
365                t.updated_at = now_unix();
366            }
367        })
368        .await?;
369        Ok(())
370    }
371
372    /// Mint a short handle and register it in the `worker_handles` map.
373    /// Called immediately after the worker-token mint inside
374    /// `dispatch_attempt_with`, and issues a handle bound to the same
375    /// token fingerprint. Format is `wh-<8 hex chars>` (11 chars total),
376    /// designed to remove the base64 copy-paste failure mode.
377    async fn mint_worker_handle(&self, worker_fp: String) -> Result<String, EngineError> {
378        // The handle is a sole bearer secret on the `/v1/worker/submit`
379        // short-handle path (`submit_worker_result_trusted` skips token
380        // verification), so it must be unguessable — OS RNG, not the
381        // predictable uid counter. 8 hex chars (~4B entropy) keeps the
382        // documented `wh-<8 hex>` wire shape; collision between live
383        // handles is negligible at in-process handle counts.
384        let short = crate::types::secure_hex(4);
385        let handle = format!("wh-{short}");
386        let h = handle.clone();
387        self.with_state("mint_worker_handle", move |s| {
388            s.worker_handles.insert(h, worker_fp);
389        })
390        .await?;
391        Ok(handle)
392    }
393
394    // ═══════════════════════════════════════════════════════════════════════
395    // Session API
396    // ═══════════════════════════════════════════════════════════════════════
397
398    /// Attach a new session with default `OperatorInfo` (`Automate`, no
399    /// bridges/hooks). Shorthand for `attach_with(.., OperatorInfo::default())`.
400    pub async fn attach(
401        &self,
402        operator_id: impl Into<String>,
403        role: Role,
404        ttl: Duration,
405    ) -> Result<CapToken, EngineError> {
406        self.attach_with(
407            operator_id,
408            role,
409            ttl,
410            crate::core::ctx::OperatorInfo::default(),
411        )
412        .await
413    }
414
415    // ═══════════════════════════════════════════════════════════════════════
416    // BridgeRegistry API.
417    // ═══════════════════════════════════════════════════════════════════════
418
419    /// Register a `SeniorBridge` under a name. An existing entry with the
420    /// same name is overwritten. On the persisted-session reattach path,
421    /// the caller re-registers under the same ID beforehand and the
422    /// bridge becomes effective again.
423    pub async fn register_senior_bridge(
424        &self,
425        id: impl Into<String>,
426        bridge: Arc<dyn SeniorBridge>,
427    ) {
428        self.inner
429            .senior_bridges
430            .write()
431            .await
432            .insert(id.into(), bridge);
433    }
434
435    /// Register a `SpawnHook` under a name. An existing entry with the
436    /// same name is overwritten.
437    pub async fn register_spawn_hook(&self, id: impl Into<String>, hook: Arc<dyn SpawnHook>) {
438        self.inner.spawn_hooks.write().await.insert(id.into(), hook);
439    }
440
441    /// Register an `Operator` (a spawn-body backend) under a name. An
442    /// existing entry with the same name is overwritten.
443    /// `OperatorDelegateMiddleware` looks this up via `ctx` and, when
444    /// `kind = MainAi` / `Composite`, bypasses `inner.spawn` and calls
445    /// `operator.execute` instead.
446    pub async fn register_operator(
447        &self,
448        id: impl Into<String>,
449        operator: Arc<dyn crate::operator::Operator>,
450    ) {
451        self.inner
452            .operators
453            .write()
454            .await
455            .insert(id.into(), operator);
456    }
457
458    /// Unregister a `SeniorBridge` by name (e.g. on WebSocket disconnect
459    /// or explicit teardown). A missing ID is a no-op.
460    pub async fn unregister_senior_bridge(&self, id: &str) {
461        self.inner.senior_bridges.write().await.remove(id);
462    }
463
464    /// Unregister a `SpawnHook` by name. A missing ID is a no-op.
465    pub async fn unregister_spawn_hook(&self, id: &str) {
466        self.inner.spawn_hooks.write().await.remove(id);
467    }
468
469    /// Unregister an `Operator` backend by name. A missing ID is a no-op.
470    pub async fn unregister_operator(&self, id: &str) {
471        self.inner.operators.write().await.remove(id);
472    }
473
474    /// Snapshot the list of registered `SpawnHook` IDs (for test
475    /// observation and debugging).
476    pub async fn list_spawn_hook_ids(&self) -> Vec<String> {
477        self.inner
478            .spawn_hooks
479            .read()
480            .await
481            .keys()
482            .cloned()
483            .collect()
484    }
485
486    /// Snapshot the list of registered `SeniorBridge` IDs.
487    pub async fn list_senior_bridge_ids(&self) -> Vec<String> {
488        self.inner
489            .senior_bridges
490            .read()
491            .await
492            .keys()
493            .cloned()
494            .collect()
495    }
496
497    /// Snapshot the list of registered `Operator` IDs.
498    pub async fn list_operator_ids(&self) -> Vec<String> {
499        self.inner.operators.read().await.keys().cloned().collect()
500    }
501
502    /// Attach specifying IDs directly. The caller is expected to have
503    /// pre-registered them via `register_senior_bridge` /
504    /// `register_spawn_hook` / `register_operator`. This is the canonical
505    /// path when persistence is in play.
506    ///
507    /// `kind` is the "Runtime Global" tier of the `OperatorKind` cascade
508    /// (stored verbatim on `OperatorSession.operator_kind`): `Some(_)` is
509    /// an explicit request (including `Some(OperatorKind::Automate)`) that
510    /// outranks the BP-level tiers; `None` leaves it unspecified so the
511    /// BP-level tiers / final default decide. See
512    /// `crate::core::ctx::collapse_operator_kind`.
513    #[allow(clippy::too_many_arguments)]
514    pub async fn attach_with_ids(
515        &self,
516        operator_id: impl Into<String>,
517        role: Role,
518        ttl: Duration,
519        kind: Option<OperatorKind>,
520        bridge_id: Option<String>,
521        hook_id: Option<String>,
522        operator_backend_id: Option<String>,
523        operator_kind_overrides: HashMap<String, OperatorKind>,
524        bp_agent_kinds: HashMap<String, OperatorKind>,
525        bp_global_kind: Option<OperatorKind>,
526    ) -> Result<CapToken, EngineError> {
527        let operator_id = operator_id.into();
528        let token = self
529            .inner
530            .signer
531            .session(operator_id.clone(), role, vec!["*".into()], ttl);
532        let session_id = SessionId::new();
533        let fp = token.fingerprint();
534        let now = now_unix();
535        let token_for_store = token.clone();
536
537        self.with_state("attach_with_ids", |s| {
538            s.tokens
539                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
540            s.sessions.insert(
541                session_id.clone(),
542                OperatorSession {
543                    id: session_id.clone(),
544                    operator_id: operator_id.clone(),
545                    role,
546                    attached_at: now,
547                    last_seen: now,
548                    attached: true,
549                    owned_task_ids: Vec::new(),
550                    token_fp: fp.clone(),
551                    operator_kind: kind,
552                    runtime_agent_kinds: operator_kind_overrides,
553                    bp_agent_kinds,
554                    bp_global_kind,
555                    bridge_id,
556                    hook_id,
557                    operator_backend_id,
558                },
559            );
560            s.push_event(Event::SessionAttached {
561                session_id: session_id.clone(),
562                role,
563            });
564        })
565        .await?;
566
567        let _ = self
568            .inner
569            .event_tx
570            .send(Event::SessionAttached { session_id, role });
571        Ok(token)
572    }
573
574    /// Build an `OperatorInfo` by looking up the session's registered IDs
575    /// on the `BridgeRegistry`, plus resolving the 4-tier `OperatorKind`
576    /// cascade for `agent_name` via `crate::core::ctx::collapse_operator_kind`.
577    /// Used when `dispatch_attempt` injects `Ctx`. An unresolved ID
578    /// (nothing registered) is silently `None` — the bridge / hook simply
579    /// does not fire and the default behaviour applies.
580    async fn resolve_operator_info(
581        &self,
582        session: &OperatorSession,
583        agent_name: &str,
584    ) -> OperatorInfo {
585        let senior_bridge = if let Some(id) = &session.bridge_id {
586            self.inner.senior_bridges.read().await.get(id).cloned()
587        } else {
588            None
589        };
590        let spawn_hook = if let Some(id) = &session.hook_id {
591            self.inner.spawn_hooks.read().await.get(id).cloned()
592        } else {
593            None
594        };
595        let operator = if let Some(id) = &session.operator_backend_id {
596            self.inner.operators.read().await.get(id).cloned()
597        } else {
598            None
599        };
600        let runtime_agent = session.runtime_agent_kinds.get(agent_name).copied();
601        // "Runtime Global" tier: `Some(_)` is always an explicit request
602        // (see the field doc on `OperatorSession.operator_kind`).
603        let runtime_global = session.operator_kind;
604        let bp_agent = session.bp_agent_kinds.get(agent_name).copied();
605        let bp_global = session.bp_global_kind;
606        let kind = crate::core::ctx::collapse_operator_kind(
607            runtime_agent,
608            runtime_global,
609            bp_agent,
610            bp_global,
611        );
612        OperatorInfo {
613            kind,
614            id: session.operator_id.clone(),
615            senior_bridge,
616            spawn_hook,
617            operator,
618        }
619    }
620
621    /// Convenience attach that takes an `OperatorInfo` (three
622    /// `Arc<dyn ...>` fields plus `kind`) **inline**.
623    ///
624    /// # Pipeline
625    ///
626    /// Each `Arc<dyn ...>` is auto-registered on the engine's registry
627    /// under a synthetic ID (`br-<hex>` / `hk-<hex>` / `ob-<hex>`), and
628    /// the session stores that synthetic ID. Subsequent `dispatch_attempt`
629    /// calls rebuild the `Arc`s from those IDs via
630    /// `resolve_operator_info`, and the three middlewares fire as usual.
631    ///
632    /// # ⚠ Non-persisted sessions only
633    ///
634    /// Because this API takes inline `Arc`s, the reattach path after
635    /// session persistence cannot rebuild them — the synthetic IDs are
636    /// not present in a freshly started process's registry. If you need
637    /// persistence, use [`Self::attach_with_ids`] with `register_*` calls
638    /// beforehand to go through **named IDs** instead.
639    ///
640    /// Handy for tests and short-lived in-process sessions. Production
641    /// WebSocket callbacks and the like should prefer `attach_with_ids`
642    /// as the canonical path.
643    pub async fn attach_with(
644        &self,
645        operator_id: impl Into<String>,
646        role: Role,
647        ttl: Duration,
648        operator_info: crate::core::ctx::OperatorInfo,
649    ) -> Result<CapToken, EngineError> {
650        let operator_id = operator_id.into();
651        // The caller always hands in a fully-formed `OperatorInfo`
652        // (including its `kind`), so it is stored as an explicit "Runtime
653        // Global" tier request (`Some(kind)`) — this path never persists
654        // BP-level tiers (both stay empty below), so `Some(kind)` resolves
655        // to the same `kind` at dispatch either way; see
656        // `OperatorSession.operator_kind` doc.
657        let kind = operator_info.kind;
658        // BridgeRegistry auto-register: when the caller hands in an
659        // `Arc<dyn>` directly, register it under a synthesised ID (the inline
660        // path aware of persistence). Callers who want to pre-register with a
661        // named ID should use `register_senior_bridge` / `register_spawn_hook`
662        // + `attach_with_ids`.
663        let bridge_id = if let Some(bridge) = operator_info.senior_bridge.clone() {
664            let id = format!("br-{}", crate::types::uid_hex(8));
665            self.inner
666                .senior_bridges
667                .write()
668                .await
669                .insert(id.clone(), bridge);
670            Some(id)
671        } else {
672            None
673        };
674        let hook_id = if let Some(hook) = operator_info.spawn_hook.clone() {
675            let id = format!("hk-{}", crate::types::uid_hex(8));
676            self.inner
677                .spawn_hooks
678                .write()
679                .await
680                .insert(id.clone(), hook);
681            Some(id)
682        } else {
683            None
684        };
685        let operator_backend_id = if let Some(operator) = operator_info.operator.clone() {
686            // `ob-` = operator-backend registry id. Renamed from `op-` in the
687            // issue #11 prefix reconciliation: `op-` used to collide with the
688            // WS operator sid shape (now unified into `S-<hex>` anyway), and a
689            // shared prefix across two unrelated registries made log filtering
690            // by prefix silently ambiguous.
691            let id = format!("ob-{}", crate::types::uid_hex(8));
692            self.inner
693                .operators
694                .write()
695                .await
696                .insert(id.clone(), operator);
697            Some(id)
698        } else {
699            None
700        };
701
702        let token = self
703            .inner
704            .signer
705            .session(operator_id.clone(), role, vec!["*".into()], ttl);
706        let session_id = SessionId::new();
707        let fp = token.fingerprint();
708        let now = now_unix();
709        let token_for_store = token.clone();
710
711        self.with_state("attach_with", |s| {
712            s.tokens
713                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
714            s.sessions.insert(
715                session_id.clone(),
716                OperatorSession {
717                    id: session_id.clone(),
718                    operator_id,
719                    role,
720                    attached_at: now,
721                    last_seen: now,
722                    attached: true,
723                    owned_task_ids: Vec::new(),
724                    token_fp: fp.clone(),
725                    operator_kind: Some(kind),
726                    runtime_agent_kinds: HashMap::new(),
727                    bp_agent_kinds: HashMap::new(),
728                    bp_global_kind: None,
729                    bridge_id,
730                    hook_id,
731                    operator_backend_id,
732                },
733            );
734            s.push_event(Event::SessionAttached {
735                session_id: session_id.clone(),
736                role,
737            });
738        })
739        .await?;
740
741        let _ = self
742            .inner
743            .event_tx
744            .send(Event::SessionAttached { session_id, role });
745        Ok(token)
746    }
747
748    /// Mark the session bound to `token` as detached (`attached = false`).
749    /// Tasks are left in place — a later `attach`/`attach_with_ids` call
750    /// carrying the same registered bridge/hook IDs can pick them back up.
751    pub async fn detach(&self, token: &CapToken) -> Result<(), EngineError> {
752        self.verify_token(token, Verb::DetachSession).await?;
753        let fp = token.fingerprint();
754        self.with_state("detach", move |s| {
755            let sid = s
756                .sessions
757                .iter()
758                .find(|(_, sess)| sess.token_fp == fp)
759                .map(|(id, _)| id.clone());
760            if let Some(sid) = sid {
761                if let Some(sess) = s.sessions.get_mut(&sid) {
762                    sess.attached = false;
763                }
764                s.push_event(Event::SessionDetached {
765                    session_id: sid.clone(),
766                });
767                let _ = sid;
768            }
769        })
770        .await?;
771        Ok(())
772    }
773
774    /// Refresh the session's `last_seen` timestamp and mark it `attached`.
775    /// Called periodically by an attached client to avoid being flipped to
776    /// detached by `start_detach_loop`.
777    pub async fn heartbeat(&self, token: &CapToken) -> Result<(), EngineError> {
778        self.verify_token(token, Verb::Heartbeat).await?;
779        let now = now_unix();
780        let fp = token.fingerprint();
781        self.with_state("heartbeat", move |s| {
782            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
783                sess.last_seen = now;
784                sess.attached = true;
785            }
786        })
787        .await?;
788        Ok(())
789    }
790
791    // ═══════════════════════════════════════════════════════════════════════
792    // Task lifecycle
793    // ═══════════════════════════════════════════════════════════════════════
794
795    /// Create a new `TaskState` from `spec` and register its initial
796    /// prompt. When the calling token is a Worker (i.e. this is a
797    /// recursive spawn), the new task inherits `parent.spawn_depth + 1`
798    /// and is rejected with `SpawnDepthExceeded` once `max_spawn_depth` is
799    /// hit; an Operator-issued call starts at depth 0.
800    pub async fn start_task(
801        &self,
802        token: &CapToken,
803        spec: TaskSpec,
804    ) -> Result<StepId, EngineError> {
805        self.verify_token(token, Verb::StartTask).await?;
806        let task_id = StepId::new();
807        let directive = spec.initial_directive.clone();
808        let task_id_clone = task_id.clone();
809        let fp = token.fingerprint();
810        let max_depth = self.inner.cfg.max_spawn_depth;
811        self.with_state("start_task", move |s| {
812            // Recursive swarm depth gate (recursion guard):
813            // Worker tokens carry CapTokenRecord.parent_task_id. Give the
814            // child parent's spawn_depth + 1; if it exceeds `max`, raise an
815            // error. Operator tokens (parent_task_id=None) start at depth 0.
816            let parent_depth_opt = s
817                .tokens
818                .get(&fp)
819                .and_then(|rec| rec.task_id.as_ref())
820                .and_then(|tid| s.tasks.get(tid))
821                .map(|t| t.spawn_depth);
822            let depth = match parent_depth_opt {
823                Some(d) => {
824                    if d + 1 >= max_depth {
825                        return Err(EngineError::SpawnDepthExceeded {
826                            current: d + 1,
827                            max: max_depth,
828                        });
829                    }
830                    d + 1
831                }
832                None => 0,
833            };
834
835            let mut task = TaskState::new(task_id_clone.clone(), spec);
836            task.spawn_depth = depth;
837            s.tasks.insert(task_id_clone.clone(), task);
838            s.prompts.insert((task_id_clone.clone(), 1), directive);
839            // Link to the owner session (only Operator tokens match; Worker tokens have no session).
840            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
841                sess.owned_task_ids.push(task_id_clone.clone());
842            }
843            s.push_event(Event::TaskCreated {
844                task_id: task_id_clone.clone(),
845            });
846            Ok::<(), EngineError>(())
847        })
848        .await??;
849        let _ = self.inner.event_tx.send(Event::TaskCreated {
850            task_id: task_id.clone(),
851        });
852        Ok(task_id)
853    }
854
855    /// Fetch a snapshot of `TaskState` for `task_id`, subject to the
856    /// task-ownership gate (see `verify_token_for_task`).
857    pub async fn read_task_state(
858        &self,
859        token: &CapToken,
860        task_id: &StepId,
861    ) -> Result<TaskState, EngineError> {
862        self.verify_token_for_task(token, Verb::ReadTaskState, task_id)
863            .await?;
864        let task_id = task_id.clone();
865        self.with_state("read_task_state", move |s| {
866            s.tasks
867                .get(&task_id)
868                .cloned()
869                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
870        })
871        .await?
872    }
873
874    /// Mark `task_id` as `Cancelled` and wake any caller blocked in
875    /// `poll_task` for it.
876    pub async fn cancel_task(&self, token: &CapToken, task_id: &StepId) -> Result<(), EngineError> {
877        self.verify_token_for_task(token, Verb::CancelTask, task_id)
878            .await?;
879        let tid = task_id.clone();
880        self.with_state("cancel_task", move |s| {
881            let task = s
882                .tasks
883                .get_mut(&tid)
884                .ok_or_else(|| EngineError::TaskNotFound(tid.to_string()))?;
885            task.status = TaskStatus::Cancelled;
886            task.updated_at = now_unix();
887            s.push_event(Event::TaskCancelled {
888                task_id: tid.clone(),
889            });
890            Ok::<(), EngineError>(())
891        })
892        .await??;
893        self.wake_task(task_id).await?;
894        Ok(())
895    }
896
897    /// Dispatch a single attempt through the given `spawner`.
898    ///
899    /// The lock is only held for snapshot capture; the actual spawn and
900    /// completion await happen outside the lock (R3 discipline).
901    ///
902    /// Sits on the Domain side of the Data / Domain split. The dispatch
903    /// path itself does not touch big response bodies — those flow through
904    /// the Data plane (`output_store` module + sink / input_inject
905    /// `SpawnerLayer`s) around this method.
906    ///
907    /// The caller does the compile plus `service::linker::link` and
908    /// carries the same stack through each dispatch. Because the spawner
909    /// is passed per-request rather than looked up from engine-global
910    /// state, parallel requests against a single `Engine` instance
911    /// (different Blueprints, different spawners) do not race.
912    ///
913    /// `run_id`, when `Some` (issue #13 run_id propagation —
914    /// `EngineDispatcher` threads it in from its `RunContext`), is
915    /// inserted into `Ctx.meta.runtime["run_id"]` (a plain JSON string)
916    /// alongside `worker_handle`, so `Operator::execute` implementations
917    /// (e.g. `WSOperatorSession`) can read it back and surface it to the
918    /// worker (Spawn directive / prompt). `None` (every pre-existing
919    /// caller / test) omits the key entirely — unchanged behavior.
920    pub async fn dispatch_attempt_with(
921        &self,
922        token: &CapToken,
923        task_id: &StepId,
924        spawner: &Arc<dyn SpawnerAdapter>,
925        run_id: Option<&RunId>,
926    ) -> Result<DispatchOutcome, EngineError> {
927        self.verify_token(token, Verb::DispatchAttempt).await?;
928        let task_id = task_id.clone();
929
930        // 1) Under the lock: increment the attempt number, mark Running, snapshot the
931        //    prompt, and pull `operator_info` from the session so we can inject it into Ctx.
932        let fp = token.fingerprint();
933        let tid_for_prep = task_id.clone();
934        let (attempt, agent, session_snapshot) = self
935            .with_state("dispatch.prep", move |s| {
936                let task = s
937                    .tasks
938                    .get_mut(&tid_for_prep)
939                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
940                task.attempt += 1;
941                task.status = TaskStatus::Running;
942                task.updated_at = now_unix();
943                // The spawner pulls the prompt via engine.fetch_prompt. In prep,
944                // if the prompts table has no entry for this attempt yet,
945                // fall back and insert `initial_directive` so the subsequent
946                // fetch_prompt succeeds.
947                let attempt = task.attempt;
948                let initial = task.spec.initial_directive.clone();
949                s.prompts
950                    .entry((tid_for_prep.clone(), attempt))
951                    .or_insert(initial);
952                let task = s
953                    .tasks
954                    .get(&tid_for_prep)
955                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
956                let agent = task.spec.agent.clone();
957                // Session snapshot (looked up by token nonce). When no session
958                // exists (worker token invoked directly / test injection), fall
959                // back to None → default OperatorInfo.
960                let sess_clone = s
961                    .sessions
962                    .values()
963                    .find(|sess| sess.token_fp == fp)
964                    .cloned();
965                Ok::<_, EngineError>((attempt, agent, sess_clone))
966            })
967            .await??;
968        // BridgeRegistry lookup + per-agent OperatorKind cascade.
969        let operator_info = match session_snapshot {
970            Some(sess) => self.resolve_operator_info(&sess, &agent).await,
971            None => OperatorInfo::default(),
972        };
973
974        // 2) Outside the lock: worker token mint + spawn.
975        //
976        // Session-style mint (max_uses=None). Within one attempt the worker is
977        // expected to hit `verify_token + fetch_prompt + fetch_data + post_result`
978        // multiple times in order, so `one_time` would exhaust the token on the
979        // very first verb. Capability is guarded by (a) the role × verb gate and
980        // (b) the short TTL (1800s).
981        let worker_token = self.inner.signer.session(
982            format!("worker-of-{task_id}"),
983            Role::Worker,
984            vec!["*".into()],
985            Duration::from_secs(1800),
986        );
987        let worker_fp = worker_token.fingerprint();
988        let task_id_for_worker = task_id.clone();
989        let worker_token_for_store = worker_token.clone();
990        self.with_state("dispatch.mint_worker", move |s| {
991            s.tokens.insert(
992                worker_fp,
993                CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
994            );
995        })
996        .await?;
997
998        // Mint a short handle (`wh-XXXXXXXX`) and register it in worker_handles.
999        // Used by the simplified Bearer path for SubAgents (short-handle form
1000        // avoids base64 copy-paste incidents).
1001        let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1002
1003        let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1004        ctx.operator = operator_info; // activates MainAIMiddleware / Senior bridge
1005        ctx.meta
1006            .runtime
1007            .insert("worker_handle".to_string(), Value::String(worker_handle));
1008        if let Some(rid) = run_id {
1009            ctx.meta
1010                .runtime
1011                .insert("run_id".to_string(), Value::String(rid.to_string()));
1012        }
1013
1014        let worker = spawner
1015            .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1016            .await
1017            .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1018
1019        // 3) Outside the lock: await worker.join() (signal-only). WorkerError is
1020        //    stringified. The value is fetched via output_tail (sink path).
1021        let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1022
1023        // Pull the last Final from output_tail and use it as the value.
1024        let value_ok: Result<(Value, bool), String> = match signal_result {
1025            Ok(()) => {
1026                let tail = self.output_tail(&task_id, attempt).await;
1027                let last_final = tail.iter().rev().find_map(|ev| match ev {
1028                    crate::worker::output::OutputEvent::Final { content, ok } => {
1029                        Some((content.clone(), *ok))
1030                    }
1031                    _ => None,
1032                });
1033                match last_final {
1034                    Some((crate::worker::output::ContentRef::Inline { value }, ok)) => {
1035                        Ok((value, ok))
1036                    }
1037                    Some((
1038                        crate::worker::output::ContentRef::FileRef {
1039                            path,
1040                            mime,
1041                            size_hint,
1042                        },
1043                        ok,
1044                    )) => Ok((
1045                        serde_json::json!({
1046                            "file_ref": path.to_string_lossy(),
1047                            "mime": mime,
1048                            "size_hint": size_hint,
1049                        }),
1050                        ok,
1051                    )),
1052                    None => Err("no Final in output_tail".to_string()),
1053                }
1054            }
1055            Err(msg) => Err(msg),
1056        };
1057
1058        // 4) Under the lock: apply (split the borrow scope so push_event and task mut can co-exist).
1059        let outcome = self
1060            .with_state("dispatch.apply", |s| {
1061                if !s.tasks.contains_key(&task_id) {
1062                    return Err(EngineError::TaskNotFound(task_id.to_string()));
1063                }
1064                match value_ok {
1065                    Ok((value, ok)) => {
1066                        let pass = ok;
1067                        {
1068                            let task = s.tasks.get_mut(&task_id).unwrap();
1069                            task.last_result = Some(value.clone());
1070                            task.updated_at = now_unix();
1071                            task.status = if pass {
1072                                TaskStatus::Pass
1073                            } else {
1074                                TaskStatus::Blocked
1075                            };
1076                        }
1077                        s.push_event(Event::TaskAttemptCompleted {
1078                            task_id: task_id.clone(),
1079                            attempt,
1080                            result: value.clone(),
1081                        });
1082                        if pass {
1083                            s.push_event(Event::TaskPass {
1084                                task_id: task_id.clone(),
1085                                result: value.clone(),
1086                            });
1087                            Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1088                        } else {
1089                            s.push_event(Event::TaskBlocked {
1090                                task_id: task_id.clone(),
1091                                result: value.clone(),
1092                            });
1093                            Ok(DispatchOutcome::Blocked(value))
1094                        }
1095                    }
1096                    Err(msg) => {
1097                        let task = s.tasks.get_mut(&task_id).unwrap();
1098                        task.status = TaskStatus::Blocked;
1099                        task.updated_at = now_unix();
1100                        Err(EngineError::DispatchFailed(msg))
1101                    }
1102                }
1103            })
1104            .await??;
1105
1106        // event broadcast (outside the lock — push_event feeds the in-memory tail; broadcast is a separate path).
1107        let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1108            task_id: task_id.clone(),
1109            attempt,
1110            result: match &outcome {
1111                DispatchOutcome::Pass(v) | DispatchOutcome::Blocked(v) => v.clone(),
1112                _ => Value::Null,
1113            },
1114        });
1115
1116        // Wake any callers waiting in poll_task.
1117        self.wake_task(&task_id).await?;
1118
1119        Ok(outcome)
1120    }
1121
1122    // ═══════════════════════════════════════════════════════════════════════
1123    // Worker-side API (= prompt / data fetch + result post)
1124    // ═══════════════════════════════════════════════════════════════════════
1125
1126    /// Fetch the directive/prompt string for `task_id`'s current attempt.
1127    /// Falls back to `initial_directive` when no prompt has been recorded
1128    /// yet for that attempt.
1129    pub async fn fetch_prompt(
1130        &self,
1131        token: &CapToken,
1132        task_id: &StepId,
1133    ) -> Result<String, EngineError> {
1134        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1135            .await?;
1136        let task_id = task_id.clone();
1137        self.with_state("fetch_prompt", move |s| {
1138            let task = s
1139                .tasks
1140                .get(&task_id)
1141                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1142            s.prompts
1143                .get(&(task_id.clone(), task.attempt.max(1)))
1144                .cloned()
1145                .ok_or_else(|| {
1146                    EngineError::ResourceNotFound(format!(
1147                        "prompt({}, attempt={})",
1148                        task_id, task.attempt
1149                    ))
1150                })
1151        })
1152        .await?
1153    }
1154
1155    /// Combined fetch for `HTTP /v1/worker/prompt`: returns `prompt` +
1156    /// (optional) `system` + `agent` + `attempt` in a single round trip.
1157    /// The verb gate reuses `FetchPrompt` — same semantics as "the worker
1158    /// pulls its task input".
1159    ///
1160    /// `system` is the value written by `OperatorSpawner::spawn` through
1161    /// `bake_worker_system_prompt` when it ran; otherwise `None` (no
1162    /// profile present, or the bake never happened).
1163    pub async fn fetch_worker_payload(
1164        &self,
1165        token: &CapToken,
1166        task_id: &StepId,
1167    ) -> Result<crate::types::WorkerPayload, EngineError> {
1168        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1169            .await?;
1170        let task_id_clone = task_id.clone();
1171        self.with_state("fetch_worker_payload", move |s| {
1172            let task = s
1173                .tasks
1174                .get(&task_id_clone)
1175                .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1176            let attempt = task.attempt.max(1);
1177            let prompt = s
1178                .prompts
1179                .get(&(task_id_clone.clone(), attempt))
1180                .cloned()
1181                .ok_or_else(|| {
1182                    EngineError::ResourceNotFound(format!(
1183                        "prompt({}, attempt={})",
1184                        task_id_clone, attempt
1185                    ))
1186                })?;
1187            let system = s
1188                .systems
1189                .get(&(task_id_clone.clone(), attempt))
1190                .cloned()
1191                .unwrap_or(None);
1192            let agent = task.spec.agent.clone();
1193            Ok::<_, EngineError>(crate::types::WorkerPayload {
1194                task_id: task_id_clone.clone(),
1195                attempt,
1196                agent,
1197                prompt,
1198                system,
1199            })
1200        })
1201        .await?
1202    }
1203
1204    /// Fetch a worker payload via a short handle. Skips token verification
1205    /// and returns `prompt` + `system` + `agent` + `attempt` in a thin
1206    /// path. The caller is expected to have already resolved `task_id`
1207    /// via `task_id_from_handle` — the handle's presence in
1208    /// `worker_handles` means it was minted server-side and is therefore
1209    /// trusted.
1210    pub async fn fetch_worker_payload_trusted(
1211        &self,
1212        task_id: &StepId,
1213    ) -> Result<crate::types::WorkerPayload, EngineError> {
1214        let task_id_clone = task_id.clone();
1215        self.with_state("fetch_worker_payload_trusted", move |s| {
1216            let task = s
1217                .tasks
1218                .get(&task_id_clone)
1219                .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1220            let attempt = task.attempt.max(1);
1221            let prompt = s
1222                .prompts
1223                .get(&(task_id_clone.clone(), attempt))
1224                .cloned()
1225                .ok_or_else(|| {
1226                    EngineError::ResourceNotFound(format!(
1227                        "prompt({}, attempt={})",
1228                        task_id_clone, attempt
1229                    ))
1230                })?;
1231            let system = s
1232                .systems
1233                .get(&(task_id_clone.clone(), attempt))
1234                .cloned()
1235                .unwrap_or(None);
1236            let agent = task.spec.agent.clone();
1237            Ok::<_, EngineError>(crate::types::WorkerPayload {
1238                task_id: task_id_clone.clone(),
1239                attempt,
1240                agent,
1241                prompt,
1242                system,
1243            })
1244        })
1245        .await?
1246    }
1247
1248    /// Read the current attempt number for a task (server-side lookup, no
1249    /// token verification). Used on `HTTP /v1/worker/result` when the
1250    /// worker omits `attempt` and the server has to fill it in.
1251    pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
1252        let task_id = task_id.clone();
1253        self.with_state("task_attempt", move |s| {
1254            s.tasks
1255                .get(&task_id)
1256                .map(|t| t.attempt)
1257                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1258        })
1259        .await?
1260    }
1261
1262    /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
1263    /// rendered `system_prompt` into engine state. There is no verb gate
1264    /// — the only expected caller is inside the spawner. SubAgents fetch
1265    /// this alongside the prompt on the `/v1/worker/prompt` path.
1266    pub async fn bake_worker_system_prompt(
1267        &self,
1268        task_id: &StepId,
1269        attempt: u32,
1270        system: Option<String>,
1271    ) -> Result<(), EngineError> {
1272        let task_id = task_id.clone();
1273        self.with_state("bake_worker_system_prompt", move |s| {
1274            s.systems.insert((task_id, attempt), system);
1275        })
1276        .await?;
1277        Ok(())
1278    }
1279
1280    /// Fetch an arbitrary named resource previously stored via
1281    /// `set_resource`. Not task-scoped — any valid token with the
1282    /// `FetchData` verb may read any key.
1283    pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
1284        self.verify_token(token, Verb::FetchData).await?;
1285        let key = key.to_string();
1286        self.with_state("fetch_data", move |s| {
1287            s.resources
1288                .get(&key)
1289                .cloned()
1290                .ok_or(EngineError::ResourceNotFound(key))
1291        })
1292        .await?
1293    }
1294
1295    // ───────────────────────────────────────────────────────────────────────
1296    // Output path.
1297    // ───────────────────────────────────────────────────────────────────────
1298
1299    /// Send one output event from inside a `SpawnerAdapter` or worker.
1300    /// Structuring is assumed to be complete by the time we cross the
1301    /// `SpawnerAdapter` boundary; this API just appends to the
1302    /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
1303    /// the `TaskAttemptCompleted` event.
1304    ///
1305    /// This is Domain-side plumbing: it feeds the engine's verdict flow,
1306    /// not the Data-plane store in the `output_store` module. It also
1307    /// does not wake the dispatch path — that is done through the
1308    /// spawner's completion oneshot when the worker terminates.
1309    pub async fn submit_output(
1310        &self,
1311        token: &crate::types::CapToken,
1312        task_id: &StepId,
1313        attempt: u32,
1314        event: crate::worker::output::OutputEvent,
1315    ) -> Result<(), EngineError> {
1316        self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
1317            .await?;
1318        let task_id_for_apply = task_id.clone();
1319        let event_clone = event.clone();
1320        self.with_state("submit_output", move |s| {
1321            s.output_store
1322                .entry((task_id_for_apply.clone(), attempt))
1323                .or_default()
1324                .push(event_clone.clone());
1325            s.push_event(crate::core::state::Event::WorkerOutput {
1326                task_id: task_id_for_apply,
1327                attempt,
1328                event: event_clone,
1329            });
1330        })
1331        .await?;
1332        Ok(())
1333    }
1334
1335    /// Snapshot the entire output tail for a given `(task_id, attempt)`.
1336    /// Used by the dispatch path when pulling `Final`, and by observers
1337    /// reading the trace.
1338    pub async fn output_tail(
1339        &self,
1340        task_id: &StepId,
1341        attempt: u32,
1342    ) -> Vec<crate::worker::output::OutputEvent> {
1343        let key = (task_id.clone(), attempt);
1344        self.with_state("output_tail", move |s| {
1345            s.output_store.get(&key).cloned().unwrap_or_default()
1346        })
1347        .await
1348        .unwrap_or_default()
1349    }
1350
1351    /// Record an interim `last_result` for `task_id` without changing its
1352    /// `status`. Distinct from the terminal `Final` output event handled
1353    /// through `submit_output` / `dispatch_attempt_with`.
1354    pub async fn post_result(
1355        &self,
1356        token: &CapToken,
1357        task_id: &StepId,
1358        result: Value,
1359    ) -> Result<(), EngineError> {
1360        self.verify_token_for_task(token, Verb::PostResult, task_id)
1361            .await?;
1362        let task_id = task_id.clone();
1363        let result_clone = result.clone();
1364        self.with_state("post_result", move |s| {
1365            let task = s
1366                .tasks
1367                .get_mut(&task_id)
1368                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1369            task.last_result = Some(result_clone);
1370            task.updated_at = now_unix();
1371            Ok::<(), EngineError>(())
1372        })
1373        .await??;
1374        Ok(())
1375    }
1376
1377    /// Store a named resource value, retrievable later via `fetch_data`.
1378    /// No token is required — this is a server-side/admin-style setter
1379    /// (mirrors `bake_worker_system_prompt`).
1380    pub async fn set_resource(
1381        &self,
1382        key: impl Into<String>,
1383        value: Value,
1384    ) -> Result<(), EngineError> {
1385        let key = key.into();
1386        self.with_state("set_resource", move |s| {
1387            s.resources.insert(key, value);
1388        })
1389        .await?;
1390        Ok(())
1391    }
1392
1393    // ═══════════════════════════════════════════════════════════════════════
1394    // Senior suspend / resume
1395    // ═══════════════════════════════════════════════════════════════════════
1396
1397    /// Ask a question of the Senior, mark the task `Suspended`, and
1398    /// return a `ResumeKey`. The suspended state persists until another
1399    /// task calls `resume(key, answer)`.
1400    ///
1401    /// Resume-side waiting is `Notify`-based, so a caller (typically
1402    /// MainAI) can detach, reattach from a different process, and still
1403    /// pull the answer out via `await_resume(key, timeout)` — the answer
1404    /// is stored inside `EngineState`.
1405    pub async fn query_senior(
1406        &self,
1407        token: &CapToken,
1408        task_id: &StepId,
1409        question: Value,
1410    ) -> Result<ResumeKey, EngineError> {
1411        self.verify_token(token, Verb::QuerySenior).await?;
1412        let task_id = task_id.clone();
1413        let key = ResumeKey::for_senior(&task_id);
1414        let task_notify = self
1415            .with_state("query_senior.notify_ensure", |s| {
1416                s.ensure_task_notify(&task_id)
1417            })
1418            .await?;
1419
1420        let key_clone = key.clone();
1421        let task_id_inner = task_id.clone();
1422        let question_clone = question.clone();
1423        self.with_state("query_senior.suspend", move |s| {
1424            let task = s
1425                .tasks
1426                .get_mut(&task_id_inner)
1427                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
1428            task.status = TaskStatus::Suspended;
1429            task.suspended_on = Some(key_clone.clone());
1430            task.updated_at = now_unix();
1431            s.pending_resumes
1432                .insert(key_clone.clone(), ResumePending::new());
1433            s.push_event(Event::SeniorQueried {
1434                task_id: task_id_inner.clone(),
1435                question: question_clone.clone(),
1436            });
1437            s.push_event(Event::TaskSuspended {
1438                task_id: task_id_inner.clone(),
1439                key: key_clone.clone(),
1440            });
1441            Ok::<(), EngineError>(())
1442        })
1443        .await??;
1444
1445        // Notify callers waiting for a task status change (Running → Suspended).
1446        task_notify.notify_waiters();
1447
1448        let _ = self
1449            .inner
1450            .event_tx
1451            .send(Event::SeniorQueried { task_id, question });
1452        Ok(key)
1453    }
1454
1455    /// Store the answer for a `ResumeKey` in `EngineState` and wake the
1456    /// waiting caller via `Notify`. Also flips the suspended task's
1457    /// status back to `Running` and fires the per-task notifier.
1458    pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
1459        let answer_for_state = answer.clone();
1460        let answer_for_event = answer.clone();
1461        let key_clone = key.clone();
1462        let (notify, task_notify, task_id_opt) = self
1463            .with_state("resume.set", move |s| {
1464                let pending = s
1465                    .pending_resumes
1466                    .get_mut(&key_clone)
1467                    .ok_or(EngineError::ResumeKeyNotFound)?;
1468                pending.answer = Some(answer_for_state);
1469                let notify = pending.notify.clone();
1470
1471                let task_id = s
1472                    .tasks
1473                    .iter()
1474                    .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
1475                    .map(|(id, _)| id.clone());
1476
1477                let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
1478
1479                if let Some(tid) = &task_id {
1480                    if let Some(task) = s.tasks.get_mut(tid) {
1481                        task.suspended_on = None;
1482                        task.status = TaskStatus::Running;
1483                        task.updated_at = now_unix();
1484                    }
1485                    s.push_event(Event::TaskResumed {
1486                        task_id: tid.clone(),
1487                        key: key_clone.clone(),
1488                    });
1489                    s.push_event(Event::SeniorAnswered {
1490                        task_id: tid.clone(),
1491                        answer: answer_for_event.clone(),
1492                    });
1493                }
1494                Ok::<_, EngineError>((notify, task_notify, task_id))
1495            })
1496            .await??;
1497
1498        // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
1499        notify.notify_waiters();
1500        if let Some(n) = task_notify {
1501            n.notify_waiters();
1502        }
1503
1504        if let Some(tid) = task_id_opt {
1505            let _ = self
1506                .inner
1507                .event_tx
1508                .send(Event::TaskResumed { task_id: tid, key });
1509        }
1510        Ok(())
1511    }
1512
1513    /// Wait for the resume answer. Even if the caller (an Operator)
1514    /// detached and reattached, the answer is available immediately here
1515    /// — if it was already stored, this returns without waiting on the
1516    /// notifier.
1517    ///
1518    /// `timeout = Duration::ZERO` performs an instant check without
1519    /// waiting.
1520    pub async fn await_resume(
1521        &self,
1522        key: ResumeKey,
1523        timeout: Duration,
1524    ) -> Result<Value, EngineError> {
1525        // (1) Under the lock: clone the notify handle and check for an existing answer.
1526        let key_clone = key.clone();
1527        let (notify, existing) = self
1528            .with_state("await_resume.snapshot", move |s| {
1529                let pending = s
1530                    .pending_resumes
1531                    .get(&key_clone)
1532                    .ok_or(EngineError::ResumeKeyNotFound)?;
1533                Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
1534            })
1535            .await??;
1536
1537        // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
1538        if let Some(v) = existing {
1539            return Ok(v);
1540        }
1541
1542        // (3) Outside the lock: wait on the notify with a timeout.
1543        if timeout.is_zero() {
1544            return Err(EngineError::PollTimeout);
1545        }
1546        let waited = tokio::time::timeout(timeout, notify.notified()).await;
1547        if waited.is_err() {
1548            return Err(EngineError::PollTimeout);
1549        }
1550
1551        // (4) Under the lock: re-read the answer (should be present now that we were notified).
1552        let key_clone = key.clone();
1553        self.with_state("await_resume.read", move |s| {
1554            let pending = s
1555                .pending_resumes
1556                .get(&key_clone)
1557                .ok_or(EngineError::ResumeKeyNotFound)?;
1558            pending
1559                .answer
1560                .clone()
1561                .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
1562        })
1563        .await?
1564    }
1565
1566    // ═══════════════════════════════════════════════════════════════════════
1567    // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
1568    // ═══════════════════════════════════════════════════════════════════════
1569
1570    /// Wait until the task's status **transitions to terminal or
1571    /// `Suspended`**, then return the latest `TaskState`. Returns
1572    /// immediately if the task is already in a terminal state.
1573    /// Exceeding the timeout returns `EngineError::PollTimeout`.
1574    ///
1575    /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
1576    /// (no wait). Larger holds — tens of minutes up to days — are fine;
1577    /// the wait state is kept in memory inside the engine and does not
1578    /// degrade.
1579    pub async fn poll_task(
1580        &self,
1581        token: &CapToken,
1582        task_id: &StepId,
1583        hold: Duration,
1584    ) -> Result<TaskState, EngineError> {
1585        self.verify_token_for_task(token, Verb::PollTask, task_id)
1586            .await?;
1587        let task_id_inner = task_id.clone();
1588
1589        // (1) Under the lock: take a snapshot and clone task_notify.
1590        let (state, notify) = self
1591            .with_state("poll_task.snapshot", move |s| {
1592                let task = s
1593                    .tasks
1594                    .get(&task_id_inner)
1595                    .cloned()
1596                    .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
1597                let notify = s.ensure_task_notify(&task_id_inner);
1598                Ok::<_, EngineError>((task, notify))
1599            })
1600            .await??;
1601
1602        // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
1603        if matches!(
1604            state.status,
1605            TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
1606        ) {
1607            return Ok(state);
1608        }
1609        if hold.is_zero() {
1610            return Ok(state);
1611        }
1612
1613        // (3) Outside the lock: wait on Notify with a timeout.
1614        let waited = tokio::time::timeout(hold, notify.notified()).await;
1615        if waited.is_err() {
1616            return Err(EngineError::PollTimeout);
1617        }
1618
1619        // (4) Under the lock: take a fresh snapshot.
1620        let task_id_inner = task_id.clone();
1621        self.with_state("poll_task.reread", move |s| {
1622            s.tasks
1623                .get(&task_id_inner)
1624                .cloned()
1625                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
1626        })
1627        .await?
1628    }
1629
1630    // ═══════════════════════════════════════════════════════════════════════
1631    // Background: heartbeat miss → detach loop
1632    // ═══════════════════════════════════════════════════════════════════════
1633
1634    /// Background loop that scans sessions every `heartbeat_interval` and
1635    /// flips `attached = false` on any session whose `last_seen` exceeds
1636    /// `heartbeat_miss_threshold * interval`.
1637    ///
1638    /// The tasks themselves are kept (assuming
1639    /// `keepalive_on_idle = true`), so another client can reattach with
1640    /// the same token and resume immediately. Dropping the returned
1641    /// `JoinHandle` does not stop the loop — the handle exists so callers
1642    /// who want to abort can hold onto it.
1643    pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
1644        let engine = self.clone();
1645        let cfg = self.inner.cfg.long_hold.clone();
1646        let interval = cfg.heartbeat_interval;
1647        let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
1648
1649        tokio::spawn(async move {
1650            let mut ticker = tokio::time::interval(interval);
1651            ticker.tick().await; // first tick is immediate
1652            loop {
1653                ticker.tick().await;
1654                let now = now_unix();
1655                let detached = engine
1656                    .with_state("detach_loop.scan", |s| {
1657                        let mut detached = Vec::new();
1658                        for (sid, sess) in s.sessions.iter_mut() {
1659                            if !sess.attached {
1660                                continue;
1661                            }
1662                            if now.saturating_sub(sess.last_seen) >= miss_secs {
1663                                sess.attached = false;
1664                                detached.push(sid.clone());
1665                            }
1666                        }
1667                        for sid in &detached {
1668                            s.push_event(Event::SessionDetached {
1669                                session_id: sid.clone(),
1670                            });
1671                        }
1672                        detached
1673                    })
1674                    .await
1675                    .unwrap_or_default();
1676                for sid in detached {
1677                    let _ = engine
1678                        .inner
1679                        .event_tx
1680                        .send(Event::SessionDetached { session_id: sid });
1681                }
1682            }
1683        })
1684    }
1685
1686    /// Helper: wake a task whose status has changed. Called from the
1687    /// method body outside the lock.
1688    async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
1689        let task_id = task_id.clone();
1690        let notify_opt = self
1691            .with_state("wake_task.get_notify", move |s| {
1692                s.task_notifies.get(&task_id).cloned()
1693            })
1694            .await?;
1695        if let Some(n) = notify_opt {
1696            n.notify_waiters();
1697        }
1698        Ok(())
1699    }
1700}
1701
1702// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
1703#[cfg(test)]
1704mod token_fingerprint_store_tests {
1705    use super::*;
1706
1707    /// A token that was never attached fails verify with a `TokenNotFound`
1708    /// that carries the fingerprint — never the nonce. The error string can
1709    /// surface in HTTP error bodies, so this is the secret-hygiene contract.
1710    #[tokio::test]
1711    async fn verify_unknown_token_reports_fingerprint_not_nonce() {
1712        let engine = Engine::new(EngineCfg::default());
1713        // Signed by the engine's own signer (sig passes) but never inserted
1714        // into the store — verify must fail at step (4), the store lookup.
1715        let token = engine.signer().session(
1716            "ghost",
1717            Role::Operator,
1718            vec!["*".into()],
1719            Duration::from_secs(60),
1720        );
1721        let err = engine
1722            .verify_token(&token, Verb::ReadTaskState)
1723            .await
1724            .expect_err("token is not in the store");
1725        let msg = err.to_string();
1726        assert!(
1727            msg.contains(&token.fingerprint()),
1728            "error must carry the fingerprint: {msg}"
1729        );
1730        assert!(
1731            !msg.contains(&token.nonce),
1732            "error must not leak the nonce: {msg}"
1733        );
1734    }
1735
1736    /// attach → verify → heartbeat → detach all resolve the session /
1737    /// token record through fingerprint keys (mint/verify lifecycle
1738    /// regression guard for the issue #14 key migration).
1739    #[tokio::test]
1740    async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
1741        let engine = Engine::new(EngineCfg::default());
1742        let token = engine
1743            .attach("op-1", Role::Operator, Duration::from_secs(60))
1744            .await
1745            .expect("attach");
1746        engine
1747            .verify_token(&token, Verb::ReadTaskState)
1748            .await
1749            .expect("verify consumes via fp key");
1750        engine
1751            .heartbeat(&token)
1752            .await
1753            .expect("heartbeat finds the session by fp");
1754        engine
1755            .detach(&token)
1756            .await
1757            .expect("detach finds the session by fp");
1758    }
1759}
1760
1761// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
1762//
1763// Regression coverage for the "explicit Automate is indistinguishable from
1764// unspecified" defect: `OperatorSession.operator_kind` (and the
1765// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
1766// so `Some(Automate)` is an explicit Runtime Global request that must
1767// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
1768// the real `resolve_operator_info` cascade path (not just
1769// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
1770// exactly as `TaskLaunchService::launch` does.
1771#[cfg(test)]
1772mod resolve_operator_info_runtime_global_tests {
1773    use super::*;
1774
1775    async fn attach_and_resolve(
1776        runtime_global: Option<OperatorKind>,
1777        bp_global: Option<OperatorKind>,
1778    ) -> OperatorInfo {
1779        let engine = Engine::new(EngineCfg::default());
1780        let token = engine
1781            .attach_with_ids(
1782                "ut-op",
1783                Role::Operator,
1784                Duration::from_secs(30),
1785                runtime_global,
1786                None,
1787                None,
1788                None,
1789                HashMap::new(),
1790                HashMap::new(),
1791                bp_global,
1792            )
1793            .await
1794            .expect("attach_with_ids ok");
1795        let session = engine
1796            .with_state("test.find_session", |s| {
1797                s.sessions
1798                    .values()
1799                    .find(|sess| sess.token_fp == token.fingerprint())
1800                    .cloned()
1801            })
1802            .await
1803            .expect("with_state ok")
1804            .expect("session present after attach_with_ids");
1805        engine.resolve_operator_info(&session, "agent-x").await
1806    }
1807
1808    #[tokio::test]
1809    async fn explicit_some_automate_outranks_bp_global_main_ai() {
1810        // Runtime Global explicitly requests Automate; bp_global is MainAi.
1811        // The explicit `Some(Automate)` must win — this is exactly the case
1812        // the old `== OperatorKind::default()` convention got wrong (it
1813        // could not tell "explicitly Automate" from "unspecified" and would
1814        // have let `bp_global` (MainAi) take over instead).
1815        let info =
1816            attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
1817        assert_eq!(
1818            info.kind,
1819            OperatorKind::Automate,
1820            "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
1821        );
1822    }
1823
1824    #[tokio::test]
1825    async fn none_lets_bp_global_main_ai_win() {
1826        // Runtime Global left unspecified (`None`); bp_global is MainAi.
1827        // With nothing more specific set, `bp_global` must decide.
1828        let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
1829        assert_eq!(
1830            info.kind,
1831            OperatorKind::MainAi,
1832            "None runtime_global must let bp_global MainAi win"
1833        );
1834    }
1835}
1836
1837/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
1838/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
1839/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
1840/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
1841/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
1842/// fails the spawn (only the ctx snapshot matters here).
1843#[cfg(test)]
1844mod dispatch_attempt_with_run_id_tests {
1845    use super::*;
1846    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
1847    use crate::worker::Worker;
1848    use std::sync::Mutex as StdMutex;
1849
1850    struct CtxProbe {
1851        seen: Arc<StdMutex<Option<Ctx>>>,
1852    }
1853
1854    #[async_trait::async_trait]
1855    impl SpawnerAdapter for CtxProbe {
1856        async fn spawn(
1857            &self,
1858            _engine: &Engine,
1859            ctx: &Ctx,
1860            _task_id: StepId,
1861            _attempt: u32,
1862            _token: CapToken,
1863        ) -> Result<Box<dyn Worker>, SpawnError> {
1864            *self.seen.lock().unwrap() = Some(ctx.clone());
1865            Err(SpawnError::Internal("probe stop".into()))
1866        }
1867    }
1868
1869    async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
1870        let engine = Engine::new(EngineCfg::default());
1871        let token = engine
1872            .attach("ut-op", Role::Operator, Duration::from_secs(30))
1873            .await
1874            .expect("attach");
1875        let tid = engine
1876            .start_task(
1877                &token,
1878                TaskSpec {
1879                    agent: "probe".into(),
1880                    initial_directive: "hi".into(),
1881                },
1882            )
1883            .await
1884            .expect("start_task");
1885        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
1886        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
1887        // The probe always errors the spawn (`SpawnError::Internal`); we
1888        // only care about the `Ctx` snapshot it captured, so the dispatch
1889        // outcome itself (`Err`) is discarded.
1890        let _ = engine
1891            .dispatch_attempt_with(&token, &tid, &spawner, run_id)
1892            .await;
1893        let captured = seen.lock().unwrap().clone();
1894        captured.expect("inner ctx captured")
1895    }
1896
1897    #[tokio::test]
1898    async fn run_id_lands_in_ctx_meta_runtime_when_some() {
1899        let run_id = RunId::new();
1900        let observed = dispatch_with_probe(Some(&run_id)).await;
1901        assert_eq!(
1902            observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
1903            Some(run_id.as_str()),
1904            "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
1905        );
1906    }
1907
1908    #[tokio::test]
1909    async fn run_id_key_absent_when_none() {
1910        let observed = dispatch_with_probe(None).await;
1911        assert!(
1912            !observed.meta.runtime.contains_key("run_id"),
1913            "no run_id key must be injected when dispatch_attempt_with is called with None"
1914        );
1915    }
1916}