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}