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::agent_context::{RUN_ID_KEY, STEP_CTX_KEY};
12use crate::core::config::EngineCfg;
13use crate::core::ctx::{Ctx, OperatorInfo, OperatorKind, SeniorBridge, SpawnHook};
14use crate::core::errors::EngineError;
15use crate::core::state::{
16    CapTokenRecord, DispatchOutcome, EngineState, Event, EventStream, OperatorSession, ResumeKey,
17    ResumePending, TaskSpec, TaskState, TaskStatus,
18};
19use crate::types::{
20    default_role_verb_table, now_unix, CapToken, Role, RoleVerbGate, RunId, SessionId, StepId,
21    TokenSigner, Verb,
22};
23use crate::worker::adapter::SpawnerAdapter;
24use serde_json::Value;
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28use tokio::sync::{broadcast, Mutex};
29
30/// Process-wide long-running runtime. Cheap to `clone()` — an `Arc`
31/// lives inside.
32#[derive(Clone)]
33pub struct Engine {
34    inner: Arc<EngineInner>,
35}
36
37struct EngineInner {
38    state: Mutex<EngineState>,
39    cfg: EngineCfg,
40    signer: TokenSigner,
41    gate: RoleVerbGate,
42    event_tx: broadcast::Sender<Event>,
43    /// ID-keyed bridge registry (register-by-ID design). `SeniorBridge`
44    /// and `SpawnHook` are registered by ID; sessions bind to those IDs
45    /// only. Persistence stores just the ID, and on reattach the caller
46    /// re-registers under the same ID to restore presence.
47    senior_bridges: tokio::sync::RwLock<HashMap<String, Arc<dyn SeniorBridge>>>,
48    spawn_hooks: tokio::sync::RwLock<HashMap<String, Arc<dyn SpawnHook>>>,
49    /// ID registry for full-spawn Operator backends (backends that take the
50    /// entire spawn via `execute`). Sibling to `senior_bridges` /
51    /// `spawn_hooks`. `OperatorDelegateMiddleware` looks these up via
52    /// `ctx` and, when `kind = MainAi` / `Composite`, bypasses
53    /// `inner.spawn` and calls `operator.execute` instead.
54    operators: tokio::sync::RwLock<HashMap<String, Arc<dyn crate::operator::Operator>>>,
55    /// Base and hint layer factories for the `SpawnerStack`. At
56    /// `service::linker::link` time, `compiled.router` is wrapped with
57    /// the base factories plus the hint factories resolved from
58    /// `blueprint.spawner_hints.layers`. This is the engine-side
59    /// counterpart to the discipline "Flow / Blueprint doesn't spell out
60    /// middleware implementations — it declares the capabilities it needs
61    /// as hint keys".
62    layer_registry: crate::middleware::LayerRegistry,
63    /// Optional Data-plane `OutputStore` backend (subtask-4 / ST2 rework —
64    /// see `submit_output`'s doc). `None` (the default) preserves
65    /// pre-subtask-4 behavior exactly: `submit_output` /
66    /// `submit_worker_result_trusted` only touch the Domain-plane
67    /// `EngineState.output_store` HashMap, same as before this was added.
68    /// `Some` additionally dual-writes every `Final` event into this store
69    /// via [`crate::store::output::OutputStore::append`], making it
70    /// queryable (e.g. by `mlua-swarm-server`'s `GET /v1/tasks/:id/ctx`)
71    /// even for an in-flight run. A plain `std::sync::RwLock` (not
72    /// `tokio::sync::RwLock`) — set once at boot via [`Engine::set_output_store`]
73    /// from a synchronous call site (`mlua-swarm-server`'s router builder),
74    /// then only ever briefly read (clone the `Option<Arc<..>>`, never held
75    /// across an `.await`) from the async submit path.
76    data_store: std::sync::RwLock<Option<Arc<dyn crate::store::output::OutputStore>>>,
77}
78
79/// Renders a `TaskSpec.initial_directive` / `EngineState.prompts`
80/// `Value` down to the `String` shape that string-consuming boundaries
81/// require (issue #18). Strings pass through verbatim; anything else
82/// (Object / Array / Number / Bool / Null) is serde-stringified. This
83/// is the single canonical rendering — the coercion that used to sit
84/// inside `EngineDispatcher::dispatch` moved here and is invoked only
85/// at consumer boundaries: `WorkerPayload.prompt` (HTTP
86/// `/v1/worker/prompt`), `WorkerInvocation.prompt` (in-process
87/// spawners), the subprocess spawner's directive arg/stdin, and the
88/// WS Spawn frame text render (`operator_ws::session`). Everything
89/// upstream (Blueprint dispatch → engine state → `fetch_prompt` →
90/// `Operator::execute`) keeps the `Value` end-to-end.
91pub(crate) fn render_directive_to_string(v: &Value) -> String {
92    match v {
93        Value::String(s) => s.clone(),
94        other => other.to_string(),
95    }
96}
97
98impl Engine {
99    /// Backwards-compatible constructor that starts the engine without a
100    /// layer registry, preserving the signature already used by ~88
101    /// existing call sites. Use this when automatic middleware wrapping
102    /// at bind time is not needed. Callers such as `mlua-swarm-server` go through
103    /// `new_with_layers(cfg, registry)` to enable the hint-resolution path.
104    pub fn new(cfg: EngineCfg) -> Self {
105        Self::new_with_layers(cfg, crate::middleware::LayerRegistry::new())
106    }
107
108    /// Construct an `Engine` with an explicit `LayerRegistry`, enabling
109    /// hint-resolution: `spawner_hints.layers` declared on a `Blueprint`
110    /// are resolved against this registry when the spawner stack is bound
111    /// at `service::linker::link` time.
112    pub fn new_with_layers(
113        cfg: EngineCfg,
114        layer_registry: crate::middleware::LayerRegistry,
115    ) -> Self {
116        let (event_tx, _) = broadcast::channel(256);
117        let signer = TokenSigner::new(&cfg.token_secret);
118        Self {
119            inner: Arc::new(EngineInner {
120                state: Mutex::new(EngineState::new()),
121                cfg,
122                signer,
123                gate: default_role_verb_table(),
124                event_tx,
125                senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
126                spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
127                operators: tokio::sync::RwLock::new(HashMap::new()),
128                layer_registry,
129                data_store: std::sync::RwLock::new(None),
130            }),
131        }
132    }
133
134    /// Rebuild this `Engine` with a different `RoleVerbGate`. The gate is
135    /// treated as fixed-at-build-time, so this constructs a fresh
136    /// `EngineInner` (fresh empty `EngineState`) rather than mutating in
137    /// place — mainly a testing convenience for swapping gate rules.
138    pub fn with_gate(self, gate: RoleVerbGate) -> Self {
139        // The gate is fixed at build time — the intent is to build a fresh
140        // instance rather than mutating in place. As a testing convenience we
141        // do allow swapping the inner Arc. Simpler form: just rebuild
142        // Arc<EngineInner>.
143        let inner = Arc::new(EngineInner {
144            state: Mutex::new(EngineState::new()),
145            cfg: self.inner.cfg.clone(),
146            signer: self.inner.signer.clone(),
147            gate,
148            event_tx: self.inner.event_tx.clone(),
149            senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
150            spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
151            operators: tokio::sync::RwLock::new(HashMap::new()),
152            layer_registry: self.inner.layer_registry.clone(),
153            data_store: std::sync::RwLock::new(None),
154        });
155        Self { inner }
156    }
157
158    // ═══════════════════════════════════════════════════════════════════════
159    // Accessors. Production code drives execution through compile +
160    // `service::linker::link` + `dispatch_attempt_with(spawner)` inside
161    // `TaskLaunchService`; `Engine` itself is a pure execution surface — it
162    // does not own a BlueprintStore / EnhanceAdapter / Compiler, nor a
163    // global spawner (the spawner is carried per-request, never stashed on
164    // the engine).
165    // ═══════════════════════════════════════════════════════════════════════
166
167    /// Access the `EngineCfg` this engine was built with.
168    pub fn cfg(&self) -> &EngineCfg {
169        &self.inner.cfg
170    }
171
172    /// Expose the internal `LayerRegistry` — used when deriving a
173    /// sub-engine that needs the same registry re-injected. The
174    /// per-request sub-engine in `mlua-swarm-server` reads the parent engine's
175    /// registry through this accessor and passes it to
176    /// `Engine::new_with_layers(cfg, parent.layer_registry().clone())`.
177    pub fn layer_registry(&self) -> &crate::middleware::LayerRegistry {
178        &self.inner.layer_registry
179    }
180
181    /// Access the `TokenSigner` used to mint/verify `CapToken`s.
182    pub fn signer(&self) -> &TokenSigner {
183        &self.inner.signer
184    }
185
186    /// Clone a handle to the process-wide `Event` broadcast sender. Prefer
187    /// `subscribe` for a ready-to-use receiver.
188    pub fn event_tx(&self) -> broadcast::Sender<Event> {
189        self.inner.event_tx.clone()
190    }
191
192    /// Subscribe to the engine's `Event` broadcast stream.
193    pub fn subscribe(&self) -> EventStream {
194        self.inner.event_tx.subscribe()
195    }
196
197    /// Wires the Data-plane [`crate::store::output::OutputStore`] backend
198    /// used by `submit_output` / `submit_worker_result_trusted`'s
199    /// submit-time projection sink (subtask-4 / ST2 rework — see
200    /// `submit_output`'s doc). Synchronous (a plain `std::sync::RwLock`
201    /// write) so a caller can wire it up at boot from a non-`async`
202    /// context (`mlua-swarm-server`'s router builder passes the same
203    /// `Arc` it hands to its `AppState.data_store`, so `POST
204    /// /v1/data/emit` and every worker's ordinary `/v1/worker/submit` land
205    /// in the one store). Calling this more than once replaces the
206    /// previous backend; not calling it at all (the default) preserves
207    /// pre-subtask-4 behavior exactly — `submit_output` only touches the
208    /// Domain-plane `EngineState.output_store` HashMap.
209    pub fn set_output_store(&self, store: Arc<dyn crate::store::output::OutputStore>) {
210        let mut guard = self
211            .inner
212            .data_store
213            .write()
214            .unwrap_or_else(|poisoned| poisoned.into_inner());
215        *guard = Some(store);
216    }
217
218    /// Clones the currently-wired Data-plane store handle, if any. Kept
219    /// private and side-effect-free (no lock held past this call) —
220    /// callers (`materialize_final_submission`) do their actual `.append`
221    /// work outside of any lock.
222    fn output_store_backend(&self) -> Option<Arc<dyn crate::store::output::OutputStore>> {
223        self.inner
224            .data_store
225            .read()
226            .unwrap_or_else(|poisoned| poisoned.into_inner())
227            .clone()
228    }
229
230    // ═══════════════════════════════════════════════════════════════════════
231    // §7 with_state — single Mutex + R1-R4 (try_lock + bounded retry + max-hold panic)
232    // ═══════════════════════════════════════════════════════════════════════
233
234    /// The closure is a **sync** `FnOnce` — you cannot pass an async
235    /// closure, which enforces R3 at the type level. Exceeding `max_hold`
236    /// panics so that R4 violations surface immediately.
237    pub async fn with_state<F, R>(&self, op: &'static str, f: F) -> Result<R, EngineError>
238    where
239        F: FnOnce(&mut EngineState) -> R,
240    {
241        let cfg = &self.inner.cfg;
242
243        // R2: try_lock + bounded retry
244        let mut guard_opt = None;
245        for attempt in 0..=cfg.max_retry {
246            match self.inner.state.try_lock() {
247                Ok(g) => {
248                    guard_opt = Some(g);
249                    break;
250                }
251                Err(_) if cfg.try_only => return Err(EngineError::LockBusy(op)),
252                Err(_) => {
253                    let backoff = cfg.backoff_ms_step * (attempt as u64 + 1);
254                    tokio::time::sleep(Duration::from_millis(backoff)).await;
255                }
256            }
257        }
258        let mut guard = guard_opt.ok_or(EngineError::LockBusyAfterRetry(op))?;
259
260        // R4: max_hold guard
261        let start = Instant::now();
262        let result = f(&mut guard);
263        let elapsed_ms = start.elapsed().as_millis();
264        drop(guard);
265
266        if elapsed_ms > cfg.max_hold_ms {
267            panic!(
268                "Engine.with_state('{op}') held {elapsed_ms}ms > max {}ms — suspected R3 violation (long op inside lock)",
269                cfg.max_hold_ms
270            );
271        }
272        Ok(result)
273    }
274
275    // ═══════════════════════════════════════════════════════════════════════
276    // Token verify (= sig + expire + gate + uses_left)
277    // ═══════════════════════════════════════════════════════════════════════
278
279    /// Four steps: (1) signature verify, (2) expiry check, (3) role × verb
280    /// gate, (4) `uses_left` consume.
281    pub async fn verify_token(&self, token: &CapToken, verb: Verb) -> Result<(), EngineError> {
282        // (1) sig
283        if !self.inner.signer.verify_sig(token) {
284            return Err(EngineError::BadSignature);
285        }
286        // (2) expire
287        if token.is_expired(now_unix()) {
288            return Err(EngineError::TokenExpired);
289        }
290        // (3) role × verb gate
291        if !self.inner.gate.is_allowed(token.role, verb) {
292            return Err(EngineError::RoleViolation {
293                role: token.role,
294                verb,
295            });
296        }
297        // (4) server-side uses_left consume
298        let fp = token.fingerprint();
299        self.with_state("token.consume", move |s| {
300            let rec = s
301                .tokens
302                .get_mut(&fp)
303                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))?;
304            rec.consume()
305                .map_err(|_: crate::core::state::CapTokenConsumeError| {
306                    EngineError::TokenUsesExhausted
307                })?;
308            Ok::<(), EngineError>(())
309        })
310        .await??;
311        Ok(())
312    }
313
314    /// `verify_token` plus the **task-ownership gate**.
315    ///
316    /// When a Worker-role token calls a state-touch verb (`fetch_prompt` /
317    /// `post_result` / `read_task_state` / `cancel_task` / `poll_task`),
318    /// the gate checks that `CapTokenRecord.task_id` matches the argument
319    /// `task_id`; a mismatch returns `EngineError::TokenTaskMismatch`.
320    /// Operator / Senior / Observer tokens are outside the ownership gate
321    /// and may touch any task.
322    ///
323    /// **Verbs exempt from the gate.** `start_task` and `dispatch_attempt`
324    /// stay outside so recursive swarming keeps working; depth is capped
325    /// by `max_spawn_depth`.
326    pub async fn verify_token_for_task(
327        &self,
328        token: &CapToken,
329        verb: Verb,
330        task_id: &StepId,
331    ) -> Result<(), EngineError> {
332        self.verify_token(token, verb).await?;
333        if token.role != Role::Worker {
334            return Ok(());
335        }
336        let fp = token.fingerprint();
337        let arg_tid = task_id.clone();
338        self.with_state("token.ownership_gate", move |s| {
339            let bound = s.tokens.get(&fp).and_then(|r| r.task_id.as_ref()).cloned();
340            match bound {
341                Some(t) if t == arg_tid => Ok(()),
342                Some(t) => Err(EngineError::TokenTaskMismatch {
343                    bound: t.into_string(),
344                    arg: arg_tid.into_string(),
345                }),
346                None => Err(EngineError::TokenNotFound(fp.clone())),
347            }
348        })
349        .await??;
350        Ok(())
351    }
352
353    /// Resolve the bound `task_id` from a Worker-role token. Used on the
354    /// simple `/v1/worker/submit` endpoint, where the worker POSTs with a
355    /// token but no `task_id`. Returns `Err` if the token role is not
356    /// Worker, or if no bound task is set.
357    pub async fn task_id_from_token(&self, token: &CapToken) -> Result<StepId, EngineError> {
358        if token.role != Role::Worker {
359            return Err(EngineError::RoleViolation {
360                role: token.role,
361                verb: Verb::PostResult,
362            });
363        }
364        let fp = token.fingerprint();
365        self.with_state("task_id_from_token", move |s| {
366            s.tokens
367                .get(&fp)
368                .and_then(|r| r.task_id.as_ref())
369                .cloned()
370                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))
371        })
372        .await?
373    }
374
375    /// Resolve a short worker handle (`wh-XXXXXXXX`) to the bound
376    /// `task_id`. Used on `/v1/worker/submit` when the Bearer is a short
377    /// handle string rather than a full `CapToken` JSON. A missing entry
378    /// returns `TokenNotFound`, i.e. "the handle is not in the store".
379    pub async fn task_id_from_handle(&self, handle: &str) -> Result<StepId, EngineError> {
380        let h = handle.to_string();
381        self.with_state("task_id_from_handle", move |s| {
382            let fp = s
383                .worker_handles
384                .get(&h)
385                .cloned()
386                .ok_or_else(|| EngineError::TokenNotFound(format!("handle={h}")))?;
387            s.tokens
388                .get(&fp)
389                .and_then(|r| r.task_id.as_ref())
390                .cloned()
391                .ok_or_else(|| EngineError::TokenNotFound(format!("fp={fp}")))
392        })
393        .await?
394    }
395
396    /// Submit a worker result via a short handle. Skips token verification
397    /// and updates `output_tail` `Final` + `task.last_result` directly in
398    /// a thin path. The caller is expected to have already resolved
399    /// `task_id` via `task_id_from_handle` — the handle's presence in
400    /// `worker_handles` means it was minted server-side and is therefore
401    /// trusted.
402    pub async fn submit_worker_result_trusted(
403        &self,
404        task_id: &StepId,
405        attempt: u32,
406        value: Value,
407        ok: bool,
408    ) -> Result<(), EngineError> {
409        let task_id_for_apply = task_id.clone();
410        let value_for_event = value.clone();
411        self.with_state("submit_worker_result_trusted.output", move |s| {
412            let ev = crate::worker::output::OutputEvent::Final {
413                content: crate::worker::output::ContentRef::Inline {
414                    value: value_for_event,
415                },
416                ok,
417            };
418            s.output_store
419                .entry((task_id_for_apply.clone(), attempt))
420                .or_default()
421                .push(ev.clone());
422            s.push_event(crate::core::state::Event::WorkerOutput {
423                task_id: task_id_for_apply,
424                attempt,
425                event: ev,
426            });
427        })
428        .await?;
429        let task_id_for_result = task_id.clone();
430        let value_for_result = value.clone();
431        self.with_state("submit_worker_result_trusted.last_result", move |s| {
432            if let Some(t) = s.tasks.get_mut(&task_id_for_result) {
433                t.last_result = Some(value_for_result);
434                t.updated_at = now_unix();
435            }
436        })
437        .await?;
438        // subtask-4 / ST2 rework: this path always submits a `Final` (there
439        // is no other event kind on `/v1/worker/submit`), so the
440        // submit-time projection sink always fires — see
441        // `materialize_final_submission`'s doc and `submit_output`'s
442        // Invariants (fail-open, never turns a would-have-succeeded submit
443        // into a failure).
444        let content = crate::worker::output::ContentRef::Inline { value };
445        self.materialize_final_submission(task_id, attempt, &content, ok)
446            .await;
447        Ok(())
448    }
449
450    /// Mint a short handle and register it in the `worker_handles` map.
451    /// Called immediately after the worker-token mint inside
452    /// `dispatch_attempt_with`, and issues a handle bound to the same
453    /// token fingerprint. Format is `wh-<8 hex chars>` (11 chars total),
454    /// designed to remove the base64 copy-paste failure mode.
455    async fn mint_worker_handle(&self, worker_fp: String) -> Result<String, EngineError> {
456        // The handle is a sole bearer secret on the `/v1/worker/submit`
457        // short-handle path (`submit_worker_result_trusted` skips token
458        // verification), so it must be unguessable — OS RNG, not the
459        // predictable uid counter. 8 hex chars (~4B entropy) keeps the
460        // documented `wh-<8 hex>` wire shape; collision between live
461        // handles is negligible at in-process handle counts.
462        let short = crate::types::secure_hex(4);
463        let handle = format!("wh-{short}");
464        let h = handle.clone();
465        self.with_state("mint_worker_handle", move |s| {
466            s.worker_handles.insert(h, worker_fp);
467        })
468        .await?;
469        Ok(handle)
470    }
471
472    // ═══════════════════════════════════════════════════════════════════════
473    // Session API
474    // ═══════════════════════════════════════════════════════════════════════
475
476    /// Attach a new session with default `OperatorInfo` (`Automate`, no
477    /// bridges/hooks). Shorthand for `attach_with(.., OperatorInfo::default())`.
478    pub async fn attach(
479        &self,
480        operator_id: impl Into<String>,
481        role: Role,
482        ttl: Duration,
483    ) -> Result<CapToken, EngineError> {
484        self.attach_with(
485            operator_id,
486            role,
487            ttl,
488            crate::core::ctx::OperatorInfo::default(),
489        )
490        .await
491    }
492
493    // ═══════════════════════════════════════════════════════════════════════
494    // BridgeRegistry API.
495    // ═══════════════════════════════════════════════════════════════════════
496
497    /// Register a `SeniorBridge` under a name. An existing entry with the
498    /// same name is overwritten. On the persisted-session reattach path,
499    /// the caller re-registers under the same ID beforehand and the
500    /// bridge becomes effective again.
501    pub async fn register_senior_bridge(
502        &self,
503        id: impl Into<String>,
504        bridge: Arc<dyn SeniorBridge>,
505    ) {
506        self.inner
507            .senior_bridges
508            .write()
509            .await
510            .insert(id.into(), bridge);
511    }
512
513    /// Register a `SpawnHook` under a name. An existing entry with the
514    /// same name is overwritten.
515    pub async fn register_spawn_hook(&self, id: impl Into<String>, hook: Arc<dyn SpawnHook>) {
516        self.inner.spawn_hooks.write().await.insert(id.into(), hook);
517    }
518
519    /// Register an `Operator` (a spawn-body backend) under a name. An
520    /// existing entry with the same name is overwritten.
521    /// `OperatorDelegateMiddleware` looks this up via `ctx` and, when
522    /// `kind = MainAi` / `Composite`, bypasses `inner.spawn` and calls
523    /// `operator.execute` instead.
524    pub async fn register_operator(
525        &self,
526        id: impl Into<String>,
527        operator: Arc<dyn crate::operator::Operator>,
528    ) {
529        self.inner
530            .operators
531            .write()
532            .await
533            .insert(id.into(), operator);
534    }
535
536    /// Unregister a `SeniorBridge` by name (e.g. on WebSocket disconnect
537    /// or explicit teardown). A missing ID is a no-op.
538    pub async fn unregister_senior_bridge(&self, id: &str) {
539        self.inner.senior_bridges.write().await.remove(id);
540    }
541
542    /// Unregister a `SpawnHook` by name. A missing ID is a no-op.
543    pub async fn unregister_spawn_hook(&self, id: &str) {
544        self.inner.spawn_hooks.write().await.remove(id);
545    }
546
547    /// Unregister an `Operator` backend by name. A missing ID is a no-op.
548    pub async fn unregister_operator(&self, id: &str) {
549        self.inner.operators.write().await.remove(id);
550    }
551
552    /// Snapshot the list of registered `SpawnHook` IDs (for test
553    /// observation and debugging).
554    pub async fn list_spawn_hook_ids(&self) -> Vec<String> {
555        self.inner
556            .spawn_hooks
557            .read()
558            .await
559            .keys()
560            .cloned()
561            .collect()
562    }
563
564    /// Snapshot the list of registered `SeniorBridge` IDs.
565    pub async fn list_senior_bridge_ids(&self) -> Vec<String> {
566        self.inner
567            .senior_bridges
568            .read()
569            .await
570            .keys()
571            .cloned()
572            .collect()
573    }
574
575    /// Snapshot the list of registered `Operator` IDs.
576    pub async fn list_operator_ids(&self) -> Vec<String> {
577        self.inner.operators.read().await.keys().cloned().collect()
578    }
579
580    /// Attach specifying IDs directly. The caller is expected to have
581    /// pre-registered them via `register_senior_bridge` /
582    /// `register_spawn_hook` / `register_operator`. This is the canonical
583    /// path when persistence is in play.
584    ///
585    /// `kind` is the "Runtime Global" tier of the `OperatorKind` cascade
586    /// (stored verbatim on `OperatorSession.operator_kind`): `Some(_)` is
587    /// an explicit request (including `Some(OperatorKind::Automate)`) that
588    /// outranks the BP-level tiers; `None` leaves it unspecified so the
589    /// BP-level tiers / final default decide. See
590    /// `crate::core::ctx::collapse_operator_kind`.
591    #[allow(clippy::too_many_arguments)]
592    pub async fn attach_with_ids(
593        &self,
594        operator_id: impl Into<String>,
595        role: Role,
596        ttl: Duration,
597        kind: Option<OperatorKind>,
598        bridge_id: Option<String>,
599        hook_id: Option<String>,
600        operator_backend_id: Option<String>,
601        operator_kind_overrides: HashMap<String, OperatorKind>,
602        bp_agent_kinds: HashMap<String, OperatorKind>,
603        bp_global_kind: Option<OperatorKind>,
604    ) -> Result<CapToken, EngineError> {
605        let operator_id = operator_id.into();
606        let token = self
607            .inner
608            .signer
609            .session(operator_id.clone(), role, vec!["*".into()], ttl);
610        let session_id = SessionId::new();
611        let fp = token.fingerprint();
612        let now = now_unix();
613        let token_for_store = token.clone();
614
615        self.with_state("attach_with_ids", |s| {
616            s.tokens
617                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
618            s.sessions.insert(
619                session_id.clone(),
620                OperatorSession {
621                    id: session_id.clone(),
622                    operator_id: operator_id.clone(),
623                    role,
624                    attached_at: now,
625                    last_seen: now,
626                    attached: true,
627                    owned_task_ids: Vec::new(),
628                    token_fp: fp.clone(),
629                    operator_kind: kind,
630                    runtime_agent_kinds: operator_kind_overrides,
631                    bp_agent_kinds,
632                    bp_global_kind,
633                    bridge_id,
634                    hook_id,
635                    operator_backend_id,
636                },
637            );
638            s.push_event(Event::SessionAttached {
639                session_id: session_id.clone(),
640                role,
641            });
642        })
643        .await?;
644
645        let _ = self
646            .inner
647            .event_tx
648            .send(Event::SessionAttached { session_id, role });
649        Ok(token)
650    }
651
652    /// Build an `OperatorInfo` by looking up the session's registered IDs
653    /// on the `BridgeRegistry`, plus resolving the 4-tier `OperatorKind`
654    /// cascade for `agent_name` via `crate::core::ctx::collapse_operator_kind`.
655    /// Used when `dispatch_attempt` injects `Ctx`. An unresolved ID
656    /// (nothing registered) is silently `None` — the bridge / hook simply
657    /// does not fire and the default behaviour applies.
658    async fn resolve_operator_info(
659        &self,
660        session: &OperatorSession,
661        agent_name: &str,
662    ) -> OperatorInfo {
663        let senior_bridge = if let Some(id) = &session.bridge_id {
664            self.inner.senior_bridges.read().await.get(id).cloned()
665        } else {
666            None
667        };
668        let spawn_hook = if let Some(id) = &session.hook_id {
669            self.inner.spawn_hooks.read().await.get(id).cloned()
670        } else {
671            None
672        };
673        let operator = if let Some(id) = &session.operator_backend_id {
674            self.inner.operators.read().await.get(id).cloned()
675        } else {
676            None
677        };
678        let runtime_agent = session.runtime_agent_kinds.get(agent_name).copied();
679        // "Runtime Global" tier: `Some(_)` is always an explicit request
680        // (see the field doc on `OperatorSession.operator_kind`).
681        let runtime_global = session.operator_kind;
682        let bp_agent = session.bp_agent_kinds.get(agent_name).copied();
683        let bp_global = session.bp_global_kind;
684        let kind = crate::core::ctx::collapse_operator_kind(
685            runtime_agent,
686            runtime_global,
687            bp_agent,
688            bp_global,
689        );
690        OperatorInfo {
691            kind,
692            id: session.operator_id.clone(),
693            senior_bridge,
694            spawn_hook,
695            operator,
696        }
697    }
698
699    /// Convenience attach that takes an `OperatorInfo` (three
700    /// `Arc<dyn ...>` fields plus `kind`) **inline**.
701    ///
702    /// # Pipeline
703    ///
704    /// Each `Arc<dyn ...>` is auto-registered on the engine's registry
705    /// under a synthetic ID (`br-<hex>` / `hk-<hex>` / `ob-<hex>`), and
706    /// the session stores that synthetic ID. Subsequent `dispatch_attempt`
707    /// calls rebuild the `Arc`s from those IDs via
708    /// `resolve_operator_info`, and the three middlewares fire as usual.
709    ///
710    /// # ⚠ Non-persisted sessions only
711    ///
712    /// Because this API takes inline `Arc`s, the reattach path after
713    /// session persistence cannot rebuild them — the synthetic IDs are
714    /// not present in a freshly started process's registry. If you need
715    /// persistence, use [`Self::attach_with_ids`] with `register_*` calls
716    /// beforehand to go through **named IDs** instead.
717    ///
718    /// Handy for tests and short-lived in-process sessions. Production
719    /// WebSocket callbacks and the like should prefer `attach_with_ids`
720    /// as the canonical path.
721    pub async fn attach_with(
722        &self,
723        operator_id: impl Into<String>,
724        role: Role,
725        ttl: Duration,
726        operator_info: crate::core::ctx::OperatorInfo,
727    ) -> Result<CapToken, EngineError> {
728        let operator_id = operator_id.into();
729        // The caller always hands in a fully-formed `OperatorInfo`
730        // (including its `kind`), so it is stored as an explicit "Runtime
731        // Global" tier request (`Some(kind)`) — this path never persists
732        // BP-level tiers (both stay empty below), so `Some(kind)` resolves
733        // to the same `kind` at dispatch either way; see
734        // `OperatorSession.operator_kind` doc.
735        let kind = operator_info.kind;
736        // BridgeRegistry auto-register: when the caller hands in an
737        // `Arc<dyn>` directly, register it under a synthesised ID (the inline
738        // path aware of persistence). Callers who want to pre-register with a
739        // named ID should use `register_senior_bridge` / `register_spawn_hook`
740        // + `attach_with_ids`.
741        let bridge_id = if let Some(bridge) = operator_info.senior_bridge.clone() {
742            let id = format!("br-{}", crate::types::uid_hex(8));
743            self.inner
744                .senior_bridges
745                .write()
746                .await
747                .insert(id.clone(), bridge);
748            Some(id)
749        } else {
750            None
751        };
752        let hook_id = if let Some(hook) = operator_info.spawn_hook.clone() {
753            let id = format!("hk-{}", crate::types::uid_hex(8));
754            self.inner
755                .spawn_hooks
756                .write()
757                .await
758                .insert(id.clone(), hook);
759            Some(id)
760        } else {
761            None
762        };
763        let operator_backend_id = if let Some(operator) = operator_info.operator.clone() {
764            // `ob-` = operator-backend registry id. Renamed from `op-` in the
765            // issue #11 prefix reconciliation: `op-` used to collide with the
766            // WS operator sid shape (now unified into `S-<hex>` anyway), and a
767            // shared prefix across two unrelated registries made log filtering
768            // by prefix silently ambiguous.
769            let id = format!("ob-{}", crate::types::uid_hex(8));
770            self.inner
771                .operators
772                .write()
773                .await
774                .insert(id.clone(), operator);
775            Some(id)
776        } else {
777            None
778        };
779
780        let token = self
781            .inner
782            .signer
783            .session(operator_id.clone(), role, vec!["*".into()], ttl);
784        let session_id = SessionId::new();
785        let fp = token.fingerprint();
786        let now = now_unix();
787        let token_for_store = token.clone();
788
789        self.with_state("attach_with", |s| {
790            s.tokens
791                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
792            s.sessions.insert(
793                session_id.clone(),
794                OperatorSession {
795                    id: session_id.clone(),
796                    operator_id,
797                    role,
798                    attached_at: now,
799                    last_seen: now,
800                    attached: true,
801                    owned_task_ids: Vec::new(),
802                    token_fp: fp.clone(),
803                    operator_kind: Some(kind),
804                    runtime_agent_kinds: HashMap::new(),
805                    bp_agent_kinds: HashMap::new(),
806                    bp_global_kind: None,
807                    bridge_id,
808                    hook_id,
809                    operator_backend_id,
810                },
811            );
812            s.push_event(Event::SessionAttached {
813                session_id: session_id.clone(),
814                role,
815            });
816        })
817        .await?;
818
819        let _ = self
820            .inner
821            .event_tx
822            .send(Event::SessionAttached { session_id, role });
823        Ok(token)
824    }
825
826    /// Mark the session bound to `token` as detached (`attached = false`).
827    /// Tasks are left in place — a later `attach`/`attach_with_ids` call
828    /// carrying the same registered bridge/hook IDs can pick them back up.
829    pub async fn detach(&self, token: &CapToken) -> Result<(), EngineError> {
830        self.verify_token(token, Verb::DetachSession).await?;
831        let fp = token.fingerprint();
832        self.with_state("detach", move |s| {
833            let sid = s
834                .sessions
835                .iter()
836                .find(|(_, sess)| sess.token_fp == fp)
837                .map(|(id, _)| id.clone());
838            if let Some(sid) = sid {
839                if let Some(sess) = s.sessions.get_mut(&sid) {
840                    sess.attached = false;
841                }
842                s.push_event(Event::SessionDetached {
843                    session_id: sid.clone(),
844                });
845                let _ = sid;
846            }
847        })
848        .await?;
849        Ok(())
850    }
851
852    /// Refresh the session's `last_seen` timestamp and mark it `attached`.
853    /// Called periodically by an attached client to avoid being flipped to
854    /// detached by `start_detach_loop`.
855    pub async fn heartbeat(&self, token: &CapToken) -> Result<(), EngineError> {
856        self.verify_token(token, Verb::Heartbeat).await?;
857        let now = now_unix();
858        let fp = token.fingerprint();
859        self.with_state("heartbeat", move |s| {
860            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
861                sess.last_seen = now;
862                sess.attached = true;
863            }
864        })
865        .await?;
866        Ok(())
867    }
868
869    // ═══════════════════════════════════════════════════════════════════════
870    // Task lifecycle
871    // ═══════════════════════════════════════════════════════════════════════
872
873    /// Create a new `TaskState` from `spec` and register its initial
874    /// prompt. When the calling token is a Worker (i.e. this is a
875    /// recursive spawn), the new task inherits `parent.spawn_depth + 1`
876    /// and is rejected with `SpawnDepthExceeded` once `max_spawn_depth` is
877    /// hit; an Operator-issued call starts at depth 0.
878    pub async fn start_task(
879        &self,
880        token: &CapToken,
881        spec: TaskSpec,
882    ) -> Result<StepId, EngineError> {
883        self.verify_token(token, Verb::StartTask).await?;
884        let task_id = StepId::new();
885        let initial_directive = spec.initial_directive.clone();
886        let task_id_clone = task_id.clone();
887        let fp = token.fingerprint();
888        let max_depth = self.inner.cfg.max_spawn_depth;
889        self.with_state("start_task", move |s| {
890            // Recursive swarm depth gate (recursion guard):
891            // Worker tokens carry CapTokenRecord.parent_task_id. Give the
892            // child parent's spawn_depth + 1; if it exceeds `max`, raise an
893            // error. Operator tokens (parent_task_id=None) start at depth 0.
894            let parent_depth_opt = s
895                .tokens
896                .get(&fp)
897                .and_then(|rec| rec.task_id.as_ref())
898                .and_then(|tid| s.tasks.get(tid))
899                .map(|t| t.spawn_depth);
900            let depth = match parent_depth_opt {
901                Some(d) => {
902                    if d + 1 >= max_depth {
903                        return Err(EngineError::SpawnDepthExceeded {
904                            current: d + 1,
905                            max: max_depth,
906                        });
907                    }
908                    d + 1
909                }
910                None => 0,
911            };
912
913            let mut task = TaskState::new(task_id_clone.clone(), spec);
914            task.spawn_depth = depth;
915            s.tasks.insert(task_id_clone.clone(), task);
916            s.prompts
917                .insert((task_id_clone.clone(), 1), initial_directive);
918            // Link to the owner session (only Operator tokens match; Worker tokens have no session).
919            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
920                sess.owned_task_ids.push(task_id_clone.clone());
921            }
922            s.push_event(Event::TaskCreated {
923                task_id: task_id_clone.clone(),
924            });
925            Ok::<(), EngineError>(())
926        })
927        .await??;
928        let _ = self.inner.event_tx.send(Event::TaskCreated {
929            task_id: task_id.clone(),
930        });
931        Ok(task_id)
932    }
933
934    /// Fetch a snapshot of `TaskState` for `task_id`, subject to the
935    /// task-ownership gate (see `verify_token_for_task`).
936    pub async fn read_task_state(
937        &self,
938        token: &CapToken,
939        task_id: &StepId,
940    ) -> Result<TaskState, EngineError> {
941        self.verify_token_for_task(token, Verb::ReadTaskState, task_id)
942            .await?;
943        let task_id = task_id.clone();
944        self.with_state("read_task_state", move |s| {
945            s.tasks
946                .get(&task_id)
947                .cloned()
948                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
949        })
950        .await?
951    }
952
953    /// Mark `task_id` as `Cancelled` and wake any caller blocked in
954    /// `poll_task` for it.
955    pub async fn cancel_task(&self, token: &CapToken, task_id: &StepId) -> Result<(), EngineError> {
956        self.verify_token_for_task(token, Verb::CancelTask, task_id)
957            .await?;
958        let tid = task_id.clone();
959        self.with_state("cancel_task", move |s| {
960            let task = s
961                .tasks
962                .get_mut(&tid)
963                .ok_or_else(|| EngineError::TaskNotFound(tid.to_string()))?;
964            task.status = TaskStatus::Cancelled;
965            task.updated_at = now_unix();
966            s.push_event(Event::TaskCancelled {
967                task_id: tid.clone(),
968            });
969            Ok::<(), EngineError>(())
970        })
971        .await??;
972        self.wake_task(task_id).await?;
973        Ok(())
974    }
975
976    /// Dispatch a single attempt through the given `spawner`.
977    ///
978    /// The lock is only held for snapshot capture; the actual spawn and
979    /// completion await happen outside the lock (R3 discipline).
980    ///
981    /// Sits on the Domain side of the Data / Domain split. The dispatch
982    /// path itself does not touch big response bodies — those flow through
983    /// the Data plane (`output_store` module + sink / input_inject
984    /// `SpawnerLayer`s) around this method.
985    ///
986    /// The caller does the compile plus `service::linker::link` and
987    /// carries the same stack through each dispatch. Because the spawner
988    /// is passed per-request rather than looked up from engine-global
989    /// state, parallel requests against a single `Engine` instance
990    /// (different Blueprints, different spawners) do not race.
991    ///
992    /// `run_id`, when `Some` (issue #13 run_id propagation —
993    /// `EngineDispatcher` threads it in from its `RunContext`), is
994    /// inserted into `Ctx.meta.runtime["run_id"]` (a plain JSON string)
995    /// alongside `worker_handle`, so `Operator::execute` implementations
996    /// (e.g. `WSOperatorSession`) can read it back and surface it to the
997    /// worker (Spawn directive / prompt). `None` (every pre-existing
998    /// caller / test) omits the key entirely — unchanged behavior.
999    pub async fn dispatch_attempt_with(
1000        &self,
1001        token: &CapToken,
1002        task_id: &StepId,
1003        spawner: &Arc<dyn SpawnerAdapter>,
1004        run_id: Option<&RunId>,
1005    ) -> Result<DispatchOutcome, EngineError> {
1006        self.verify_token(token, Verb::DispatchAttempt).await?;
1007        let task_id = task_id.clone();
1008
1009        // 1) Under the lock: increment the attempt number, mark Running, snapshot the
1010        //    prompt, and pull `operator_info` from the session so we can inject it into Ctx.
1011        let fp = token.fingerprint();
1012        let tid_for_prep = task_id.clone();
1013        let (attempt, agent, session_snapshot, step_ctx) = self
1014            .with_state("dispatch.prep", move |s| {
1015                let task = s
1016                    .tasks
1017                    .get_mut(&tid_for_prep)
1018                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1019                task.attempt += 1;
1020                task.status = TaskStatus::Running;
1021                task.updated_at = now_unix();
1022                // The spawner pulls the prompt via engine.fetch_prompt. In prep,
1023                // if the prompts table has no entry for this attempt yet,
1024                // fall back and insert `initial_directive` so the subsequent
1025                // fetch_prompt succeeds.
1026                let attempt = task.attempt;
1027                let initial = task.spec.initial_directive.clone();
1028                s.prompts
1029                    .entry((tid_for_prep.clone(), attempt))
1030                    .or_insert(initial);
1031                let task = s
1032                    .tasks
1033                    .get(&tid_for_prep)
1034                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1035                let agent = task.spec.agent.clone();
1036                // GH #21 Phase 2: re-read `TaskSpec.step_ctx` on EVERY
1037                // attempt (not cached once at start_task) so retries and
1038                // Run-rekicks all carry the Step tier through to Ctx —
1039                // see TaskSpec.step_ctx's doc.
1040                let step_ctx = task.spec.step_ctx.clone();
1041                // Session snapshot (looked up by token nonce). When no session
1042                // exists (worker token invoked directly / test injection), fall
1043                // back to None → default OperatorInfo.
1044                let sess_clone = s
1045                    .sessions
1046                    .values()
1047                    .find(|sess| sess.token_fp == fp)
1048                    .cloned();
1049                Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx))
1050            })
1051            .await??;
1052        // BridgeRegistry lookup + per-agent OperatorKind cascade.
1053        let operator_info = match session_snapshot {
1054            Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1055            None => OperatorInfo::default(),
1056        };
1057
1058        // 2) Outside the lock: worker token mint + spawn.
1059        //
1060        // Session-style mint (max_uses=None). Within one attempt the worker is
1061        // expected to hit `verify_token + fetch_prompt + fetch_data + post_result`
1062        // multiple times in order, so `one_time` would exhaust the token on the
1063        // very first verb. Capability is guarded by (a) the role × verb gate and
1064        // (b) the short TTL (1800s).
1065        let worker_token = self.inner.signer.session(
1066            format!("worker-of-{task_id}"),
1067            Role::Worker,
1068            vec!["*".into()],
1069            Duration::from_secs(1800),
1070        );
1071        let worker_fp = worker_token.fingerprint();
1072        let task_id_for_worker = task_id.clone();
1073        let worker_token_for_store = worker_token.clone();
1074        self.with_state("dispatch.mint_worker", move |s| {
1075            s.tokens.insert(
1076                worker_fp,
1077                CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1078            );
1079        })
1080        .await?;
1081
1082        // Mint a short handle (`wh-XXXXXXXX`) and register it in worker_handles.
1083        // Used by the simplified Bearer path for SubAgents (short-handle form
1084        // avoids base64 copy-paste incidents).
1085        let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1086
1087        let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1088        ctx.operator = operator_info; // activates MainAIMiddleware / Senior bridge
1089        ctx.meta
1090            .runtime
1091            .insert("worker_handle".to_string(), Value::String(worker_handle));
1092        if let Some(rid) = run_id {
1093            ctx.meta
1094                .runtime
1095                .insert(RUN_ID_KEY.to_string(), Value::String(rid.to_string()));
1096        }
1097        // GH #21 Phase 2: the Step tier's resolved context bundle (from
1098        // `TaskSpec.step_ctx`, re-read every attempt above) — consumed by
1099        // `AgentContextMiddleware`, which unpacks its keys ahead of the
1100        // Agent / BP-global tiers.
1101        if let Some(step_ctx) = step_ctx {
1102            ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1103        }
1104
1105        let worker = spawner
1106            .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1107            .await
1108            .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1109
1110        // 3) Outside the lock: await worker.join() (signal-only). WorkerError is
1111        //    stringified. The value is fetched via output_tail (sink path).
1112        let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1113
1114        // Pull the last Final from output_tail and use it as the value.
1115        let value_ok: Result<(Value, bool), String> = match signal_result {
1116            Ok(()) => {
1117                let tail = self.output_tail(&task_id, attempt).await;
1118                let last_final = tail.iter().rev().find_map(|ev| match ev {
1119                    crate::worker::output::OutputEvent::Final { content, ok } => {
1120                        Some((content.clone(), *ok))
1121                    }
1122                    _ => None,
1123                });
1124                match last_final {
1125                    Some((crate::worker::output::ContentRef::Inline { value }, ok)) => {
1126                        Ok((value, ok))
1127                    }
1128                    Some((
1129                        crate::worker::output::ContentRef::FileRef {
1130                            path,
1131                            mime,
1132                            size_hint,
1133                        },
1134                        ok,
1135                    )) => Ok((
1136                        serde_json::json!({
1137                            "file_ref": path.to_string_lossy(),
1138                            "mime": mime,
1139                            "size_hint": size_hint,
1140                        }),
1141                        ok,
1142                    )),
1143                    None => Err("no Final in output_tail".to_string()),
1144                }
1145            }
1146            Err(msg) => Err(msg),
1147        };
1148
1149        // 4) Under the lock: apply (split the borrow scope so push_event and task mut can co-exist).
1150        let outcome = self
1151            .with_state("dispatch.apply", |s| {
1152                if !s.tasks.contains_key(&task_id) {
1153                    return Err(EngineError::TaskNotFound(task_id.to_string()));
1154                }
1155                match value_ok {
1156                    Ok((value, ok)) => {
1157                        let pass = ok;
1158                        {
1159                            let task = s.tasks.get_mut(&task_id).unwrap();
1160                            task.last_result = Some(value.clone());
1161                            task.updated_at = now_unix();
1162                            task.status = if pass {
1163                                TaskStatus::Pass
1164                            } else {
1165                                TaskStatus::Blocked
1166                            };
1167                        }
1168                        s.push_event(Event::TaskAttemptCompleted {
1169                            task_id: task_id.clone(),
1170                            attempt,
1171                            result: value.clone(),
1172                        });
1173                        if pass {
1174                            s.push_event(Event::TaskPass {
1175                                task_id: task_id.clone(),
1176                                result: value.clone(),
1177                            });
1178                            Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1179                        } else {
1180                            s.push_event(Event::TaskBlocked {
1181                                task_id: task_id.clone(),
1182                                result: value.clone(),
1183                            });
1184                            Ok(DispatchOutcome::Blocked(value))
1185                        }
1186                    }
1187                    Err(msg) => {
1188                        let task = s.tasks.get_mut(&task_id).unwrap();
1189                        task.status = TaskStatus::Blocked;
1190                        task.updated_at = now_unix();
1191                        Err(EngineError::DispatchFailed(msg))
1192                    }
1193                }
1194            })
1195            .await??;
1196
1197        // event broadcast (outside the lock — push_event feeds the in-memory tail; broadcast is a separate path).
1198        let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1199            task_id: task_id.clone(),
1200            attempt,
1201            result: match &outcome {
1202                DispatchOutcome::Pass(v) | DispatchOutcome::Blocked(v) => v.clone(),
1203                _ => Value::Null,
1204            },
1205        });
1206
1207        // Wake any callers waiting in poll_task.
1208        self.wake_task(&task_id).await?;
1209
1210        Ok(outcome)
1211    }
1212
1213    // ═══════════════════════════════════════════════════════════════════════
1214    // Worker-side API (= prompt / data fetch + result post)
1215    // ═══════════════════════════════════════════════════════════════════════
1216
1217    /// Fetch the directive/prompt `Value` for `task_id`'s current attempt.
1218    /// Falls back to `initial_directive` when no prompt has been recorded
1219    /// yet for that attempt. Returns the `Value` end-to-end (issue #18);
1220    /// the render down to `String` happens only at the two consumer
1221    /// boundaries — the Worker HTTP path (`fetch_worker_payload*` →
1222    /// `WorkerPayload.prompt: String`) and the WS Spawn frame text
1223    /// render (`operator_ws::session`).
1224    pub async fn fetch_prompt(
1225        &self,
1226        token: &CapToken,
1227        task_id: &StepId,
1228    ) -> Result<Value, EngineError> {
1229        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1230            .await?;
1231        let task_id = task_id.clone();
1232        self.with_state("fetch_prompt", move |s| {
1233            let task = s
1234                .tasks
1235                .get(&task_id)
1236                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1237            s.prompts
1238                .get(&(task_id.clone(), task.attempt.max(1)))
1239                .cloned()
1240                .ok_or_else(|| {
1241                    EngineError::ResourceNotFound(format!(
1242                        "prompt({}, attempt={})",
1243                        task_id, task.attempt
1244                    ))
1245                })
1246        })
1247        .await?
1248    }
1249
1250    /// Combined fetch for `HTTP /v1/worker/prompt`: returns `prompt` +
1251    /// (optional) `system` + `agent` + `attempt` in a single round trip.
1252    /// The verb gate reuses `FetchPrompt` — same semantics as "the worker
1253    /// pulls its task input".
1254    ///
1255    /// `system` is the value written by `OperatorSpawner::spawn` through
1256    /// `bake_worker_system_prompt` when it ran; otherwise `None` (no
1257    /// profile present, or the bake never happened).
1258    pub async fn fetch_worker_payload(
1259        &self,
1260        token: &CapToken,
1261        task_id: &StepId,
1262    ) -> Result<crate::types::WorkerPayload, EngineError> {
1263        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1264            .await?;
1265        let task_id_clone = task_id.clone();
1266        self.with_state("fetch_worker_payload", move |s| {
1267            let task = s
1268                .tasks
1269                .get(&task_id_clone)
1270                .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1271            let attempt = task.attempt.max(1);
1272            let prompt = s
1273                .prompts
1274                .get(&(task_id_clone.clone(), attempt))
1275                .cloned()
1276                .ok_or_else(|| {
1277                    EngineError::ResourceNotFound(format!(
1278                        "prompt({}, attempt={})",
1279                        task_id_clone, attempt
1280                    ))
1281                })?;
1282            let system = s
1283                .systems
1284                .get(&(task_id_clone.clone(), attempt))
1285                .cloned()
1286                .unwrap_or(None);
1287            let agent = task.spec.agent.clone();
1288            let context = s
1289                .agent_ctx
1290                .get(&(task_id_clone.clone(), attempt))
1291                .map(|e| e.view.clone());
1292            Ok::<_, EngineError>(crate::types::WorkerPayload {
1293                task_id: task_id_clone.clone(),
1294                attempt,
1295                agent,
1296                prompt: render_directive_to_string(&prompt),
1297                system,
1298                context,
1299            })
1300        })
1301        .await?
1302    }
1303
1304    /// Fetch a worker payload via a short handle. Skips token verification
1305    /// and returns `prompt` + `system` + `agent` + `attempt` in a thin
1306    /// path. The caller is expected to have already resolved `task_id`
1307    /// via `task_id_from_handle` — the handle's presence in
1308    /// `worker_handles` means it was minted server-side and is therefore
1309    /// trusted.
1310    pub async fn fetch_worker_payload_trusted(
1311        &self,
1312        task_id: &StepId,
1313    ) -> Result<crate::types::WorkerPayload, EngineError> {
1314        let task_id_clone = task_id.clone();
1315        self.with_state("fetch_worker_payload_trusted", move |s| {
1316            let task = s
1317                .tasks
1318                .get(&task_id_clone)
1319                .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1320            let attempt = task.attempt.max(1);
1321            let prompt = s
1322                .prompts
1323                .get(&(task_id_clone.clone(), attempt))
1324                .cloned()
1325                .ok_or_else(|| {
1326                    EngineError::ResourceNotFound(format!(
1327                        "prompt({}, attempt={})",
1328                        task_id_clone, attempt
1329                    ))
1330                })?;
1331            let system = s
1332                .systems
1333                .get(&(task_id_clone.clone(), attempt))
1334                .cloned()
1335                .unwrap_or(None);
1336            let agent = task.spec.agent.clone();
1337            let context = s
1338                .agent_ctx
1339                .get(&(task_id_clone.clone(), attempt))
1340                .map(|e| e.view.clone());
1341            Ok::<_, EngineError>(crate::types::WorkerPayload {
1342                task_id: task_id_clone.clone(),
1343                attempt,
1344                agent,
1345                prompt: render_directive_to_string(&prompt),
1346                system,
1347                context,
1348            })
1349        })
1350        .await?
1351    }
1352
1353    /// Returns the effective [`mlua_swarm_schema::ContextPolicy`]
1354    /// `AgentContextMiddleware` resolved and snapshotted for `(task_id,
1355    /// attempt)` at spawn time (the same policy already applied to that
1356    /// key's `EngineState.agent_ctx` entry's `.view`, GH #23 fold).
1357    /// Pass-all (`ContextPolicy::default()`) when no entry exists — either
1358    /// a pre-ST5 spawn, or a spawner stack that never layered
1359    /// `AgentContextMiddleware` (fail-open, mirroring [`Self::output_tail`]'s
1360    /// "no entry = empty default" convention).
1361    ///
1362    /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
1363    /// handler reads this back to filter `WorkerPayload.context.steps` via
1364    /// `ContextPolicy::allows_step`, without re-deriving the policy from
1365    /// the Blueprint at fetch time (`projection-adapter` ST5).
1366    pub async fn context_policy_for(
1367        &self,
1368        task_id: &StepId,
1369        attempt: u32,
1370    ) -> mlua_swarm_schema::ContextPolicy {
1371        let key = (task_id.clone(), attempt);
1372        self.with_state("context_policy_for", move |s| {
1373            s.agent_ctx
1374                .get(&key)
1375                .map(|e| e.policy.clone())
1376                .unwrap_or_default()
1377        })
1378        .await
1379        .unwrap_or_default()
1380    }
1381
1382    /// GH #23: returns the Blueprint-wide
1383    /// [`crate::core::step_naming::StepNaming`] table snapshotted for
1384    /// `task_id` (the same `Arc` `crate::blueprint::EngineDispatcher::dispatch`
1385    /// stashed into `EngineState.step_namings` at dispatch time —
1386    /// `Self::start_task`'s `StepId`, not the `TaskId` work item). `None`
1387    /// when no entry exists — either the dispatcher was never given a
1388    /// `StepNaming` (`EngineDispatcher::with_step_naming` not called) or
1389    /// the lock could not be acquired; callers are expected to fall back
1390    /// to the pre-GH-#23 runtime union rule in that case (subtask-2/3
1391    /// consumers).
1392    pub async fn step_naming_for(
1393        &self,
1394        task_id: &StepId,
1395    ) -> Option<Arc<crate::core::step_naming::StepNaming>> {
1396        let key = task_id.clone();
1397        self.with_state("step_naming_for", move |s| {
1398            s.step_namings.get(&key).cloned()
1399        })
1400        .await
1401        .ok()
1402        .flatten()
1403    }
1404
1405    /// GH #27 (follow-up to #23): returns the Blueprint-wide
1406    /// [`crate::core::projection_placement::ProjectionPlacement`] resolver
1407    /// snapshotted for `task_id` (the same `Arc`
1408    /// `crate::blueprint::EngineDispatcher::dispatch` stashed into
1409    /// `EngineState.projection_placements` at dispatch time — mirroring
1410    /// [`Self::step_naming_for`]'s contract exactly). `None` when no entry
1411    /// exists — either the dispatcher was never given a
1412    /// `ProjectionPlacement` (`EngineDispatcher::with_projection_placement`
1413    /// not called) or the lock could not be acquired; callers are expected
1414    /// to fall back to `ProjectionPlacement::default()` (byte-compat with
1415    /// the pre-#27 hardcoded layout) in that case.
1416    pub async fn projection_placement_for(
1417        &self,
1418        task_id: &StepId,
1419    ) -> Option<Arc<crate::core::projection_placement::ProjectionPlacement>> {
1420        let key = task_id.clone();
1421        self.with_state("projection_placement_for", move |s| {
1422            s.projection_placements.get(&key).cloned()
1423        })
1424        .await
1425        .ok()
1426        .flatten()
1427    }
1428
1429    /// Returns the [`crate::core::agent_context::AgentContextView`]
1430    /// snapshotted for `(task_id, attempt)`, if `AgentContextMiddleware`
1431    /// stashed one — the same lookup [`Self::fetch_worker_payload`] /
1432    /// [`Self::fetch_worker_payload_trusted`] perform inline, exposed
1433    /// standalone for callers that only need the view (not a full
1434    /// `WorkerPayload`) — e.g. the HTTP debug-plane `GET
1435    /// /v1/tasks/:id/runs/:run/steps*` handlers resolving a
1436    /// materialized-file root for a step *other than* the one currently
1437    /// fetching its own prompt (`projection-adapter` ST5).
1438    pub async fn agent_context_for(
1439        &self,
1440        task_id: &StepId,
1441        attempt: u32,
1442    ) -> Option<crate::core::agent_context::AgentContextView> {
1443        let key = (task_id.clone(), attempt);
1444        self.with_state("agent_context_for", move |s| {
1445            s.agent_ctx.get(&key).map(|e| e.view.clone())
1446        })
1447        .await
1448        .ok()
1449        .flatten()
1450    }
1451
1452    /// Read the current attempt number for a task (server-side lookup, no
1453    /// token verification). Used on `HTTP /v1/worker/result` when the
1454    /// worker omits `attempt` and the server has to fill it in.
1455    pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
1456        let task_id = task_id.clone();
1457        self.with_state("task_attempt", move |s| {
1458            s.tasks
1459                .get(&task_id)
1460                .map(|t| t.attempt)
1461                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1462        })
1463        .await?
1464    }
1465
1466    /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
1467    /// rendered `system_prompt` into engine state. There is no verb gate
1468    /// — the only expected caller is inside the spawner. SubAgents fetch
1469    /// this alongside the prompt on the `/v1/worker/prompt` path.
1470    pub async fn bake_worker_system_prompt(
1471        &self,
1472        task_id: &StepId,
1473        attempt: u32,
1474        system: Option<String>,
1475    ) -> Result<(), EngineError> {
1476        let task_id = task_id.clone();
1477        self.with_state("bake_worker_system_prompt", move |s| {
1478            s.systems.insert((task_id, attempt), system);
1479        })
1480        .await?;
1481        Ok(())
1482    }
1483
1484    /// Fetch an arbitrary named resource previously stored via
1485    /// `set_resource`. Not task-scoped — any valid token with the
1486    /// `FetchData` verb may read any key.
1487    pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
1488        self.verify_token(token, Verb::FetchData).await?;
1489        let key = key.to_string();
1490        self.with_state("fetch_data", move |s| {
1491            s.resources
1492                .get(&key)
1493                .cloned()
1494                .ok_or(EngineError::ResourceNotFound(key))
1495        })
1496        .await?
1497    }
1498
1499    // ───────────────────────────────────────────────────────────────────────
1500    // Output path.
1501    // ───────────────────────────────────────────────────────────────────────
1502
1503    /// Send one output event from inside a `SpawnerAdapter` or worker.
1504    /// Structuring is assumed to be complete by the time we cross the
1505    /// `SpawnerAdapter` boundary; this API just appends to the
1506    /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
1507    /// the `TaskAttemptCompleted` event.
1508    ///
1509    /// This is Domain-side plumbing: it feeds the engine's verdict flow,
1510    /// not the Data-plane store in the `output_store` module. It also
1511    /// does not wake the dispatch path — that is done through the
1512    /// spawner's completion oneshot when the worker terminates.
1513    ///
1514    /// # Submit-time projection sink (subtask-4 / ST2 rework)
1515    ///
1516    /// A `Final` event additionally fans out to the submit-time projection
1517    /// sink ([`Self::materialize_final_submission`]): (a) when
1518    /// [`Self::set_output_store`] has wired a Data-plane
1519    /// [`crate::store::output::OutputStore`], the event is dual-written
1520    /// there (`producer_agent` = `TaskState.spec.agent`, resolved to its
1521    /// GH #23 canonical projection name — see below), and (b) when this
1522    /// task's spawn ran through `AgentContextMiddleware` (so
1523    /// `EngineState.agent_ctx` has a `.view.work_dir` / `.view.project_root`
1524    /// for it), the value is additionally materialized to the
1525    /// [`crate::core::projection_placement::ProjectionPlacement`]
1526    /// resolver's target (byte-compat default layout
1527    /// `<root>/workspace/tasks/<task_id>/ctx/<canonical_agent>.md`) — see
1528    /// `crate::core::projection`'s module doc.
1529    ///
1530    /// **GH #23 subtask-2 (canonical sink):** both writes above key off the
1531    /// canonical name — `Engine::step_naming_for(task_id)`'s
1532    /// `StepNaming::canonical_of_producer(producer_agent)` when a table was
1533    /// snapshotted for this task (`EngineDispatcher::with_step_naming`),
1534    /// else `producer_agent` unchanged (fail-open, byte-identical to
1535    /// pre-GH-#23 behavior — see [`crate::core::step_naming`]'s module
1536    /// doc).
1537    ///
1538    /// **Invariants** (Subtask 4): (1) this sink is fail-open — an
1539    /// unresolved root, an unconfigured `OutputStore`, or either one
1540    /// erroring, only logs a `tracing::warn!` and never turns this
1541    /// `Ok(())` into an `Err`; (2) the wired `OutputStore` stays the single
1542    /// source of truth for cross-step queries — the materialized file is a
1543    /// projection of it, not a second store; (3) core does not depend on
1544    /// `mlua-swarm-server` — everything this sink touches
1545    /// (`crate::store::output` / `crate::core::projection`) already lives
1546    /// in this crate.
1547    pub async fn submit_output(
1548        &self,
1549        token: &crate::types::CapToken,
1550        task_id: &StepId,
1551        attempt: u32,
1552        event: crate::worker::output::OutputEvent,
1553    ) -> Result<(), EngineError> {
1554        self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
1555            .await?;
1556        let task_id_for_apply = task_id.clone();
1557        let event_clone = event.clone();
1558        self.with_state("submit_output", move |s| {
1559            s.output_store
1560                .entry((task_id_for_apply.clone(), attempt))
1561                .or_default()
1562                .push(event_clone.clone());
1563            s.push_event(crate::core::state::Event::WorkerOutput {
1564                task_id: task_id_for_apply,
1565                attempt,
1566                event: event_clone,
1567            });
1568        })
1569        .await?;
1570        if let crate::worker::output::OutputEvent::Final { content, ok } = &event {
1571            self.materialize_final_submission(task_id, attempt, content, *ok)
1572                .await;
1573        }
1574        Ok(())
1575    }
1576
1577    /// Submit-time projection sink (subtask-4 / ST2 rework) shared by
1578    /// [`Self::submit_output`] and [`Self::submit_worker_result_trusted`].
1579    /// Best-effort / fail-open throughout (see `submit_output`'s doc
1580    /// Invariants): every failure path only `tracing::warn!`s and returns.
1581    ///
1582    /// Reads `(producer_agent, view)` via one read-only [`Self::with_state`]
1583    /// call — `producer_agent` off `TaskState.spec.agent`, `view` (the
1584    /// full [`crate::core::agent_context::AgentContextView`]) off
1585    /// `EngineState.agent_ctx[(task_id, attempt)]`, the same snapshot
1586    /// `crate::middleware::agent_context::AgentContextMiddleware` writes at
1587    /// spawn time — then does its actual (dual-write / file-write) work
1588    /// *outside* that lock, so a slow disk write or Data-plane store call
1589    /// never holds up unrelated `Engine::with_state` callers. `root` itself
1590    /// is resolved from `view` AFTER the lock via
1591    /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
1592    /// (GH #27, follow-up to #23) — the SAME resolver
1593    /// [`Self::step_naming_for`]'s sibling accessor
1594    /// [`Self::projection_placement_for`] snapshotted at dispatch time, so
1595    /// this sink's root-preference / fallback order is identical to the
1596    /// server read-back and the spawn-time pointer.
1597    async fn materialize_final_submission(
1598        &self,
1599        task_id: &StepId,
1600        attempt: u32,
1601        content: &crate::worker::output::ContentRef,
1602        ok: bool,
1603    ) {
1604        let task_id_for_lookup = task_id.clone();
1605        let lookup = self
1606            .with_state("materialize_final_submission.lookup", move |s| {
1607                let producer_agent = s
1608                    .tasks
1609                    .get(&task_id_for_lookup)
1610                    .map(|t| t.spec.agent.clone());
1611                let view = s
1612                    .agent_ctx
1613                    .get(&(task_id_for_lookup.clone(), attempt))
1614                    .map(|e| e.view.clone());
1615                (producer_agent, view)
1616            })
1617            .await;
1618        let (producer_agent, view) = match lookup {
1619            Ok(pair) => pair,
1620            Err(err) => {
1621                tracing::warn!(
1622                    %task_id,
1623                    error = %err,
1624                    "submit-time projection sink: state lookup failed; skipping (fail-open)"
1625                );
1626                return;
1627            }
1628        };
1629        let Some(producer_agent) = producer_agent else {
1630            // Defensive only: `task_id` is always a just-looked-up task at
1631            // every real call site. No task, no addressable producer name
1632            // — nothing to project.
1633            return;
1634        };
1635        let placement = self
1636            .projection_placement_for(task_id)
1637            .await
1638            .unwrap_or_default();
1639        let root = view.and_then(|v| placement.resolve_root(&v));
1640
1641        // GH #23 subtask-2: resolve `producer_agent` to its canonical
1642        // projection name via the Blueprint-wide `StepNaming` table
1643        // snapshotted at dispatch time (`Engine::step_naming_for`). Both
1644        // write paths below ((a) data-plane, (b) file stem) use the
1645        // *canonical* name — `StepNaming::canonical_of_producer` returns
1646        // `producer_agent` unchanged for undeclared steps (byte-identical
1647        // to pre-GH-#23 behavior), and `None` (no table for this
1648        // `task_id`, e.g. a spawn that never went through
1649        // `EngineDispatcher::with_step_naming`) is a defensive fail-open
1650        // to the raw `producer_agent`, same discipline as the rest of this
1651        // sink.
1652        let canonical_agent = self
1653            .step_naming_for(task_id)
1654            .await
1655            .and_then(|naming| {
1656                naming
1657                    .canonical_of_producer(&producer_agent)
1658                    .map(str::to_string)
1659            })
1660            .unwrap_or_else(|| producer_agent.clone());
1661
1662        // (a) Data-plane dual-write, when an OutputStore backend is wired.
1663        if let Some(store) = self.output_store_backend() {
1664            if let Err(err) = store
1665                .append(
1666                    task_id.as_str(),
1667                    attempt,
1668                    &canonical_agent,
1669                    crate::worker::output::OutputEvent::Final {
1670                        content: content.clone(),
1671                        ok,
1672                    },
1673                    Vec::new(),
1674                )
1675                .await
1676            {
1677                tracing::warn!(
1678                    %task_id,
1679                    agent = %producer_agent,
1680                    canonical = %canonical_agent,
1681                    error = %err,
1682                    "submit-time projection sink: OutputStore dual-write failed (fail-open)"
1683                );
1684            }
1685        }
1686
1687        // (b) File materialize, when a root resolved.
1688        let Some(root) = root else {
1689            tracing::warn!(
1690                %task_id,
1691                agent = %producer_agent,
1692                canonical = %canonical_agent,
1693                "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
1694            );
1695            return;
1696        };
1697        let value = match content {
1698            crate::worker::output::ContentRef::Inline { value } => value.clone(),
1699            crate::worker::output::ContentRef::FileRef {
1700                path,
1701                mime,
1702                size_hint,
1703            } => serde_json::json!({
1704                "file_ref": path.to_string_lossy(),
1705                "mime": mime,
1706                "size_hint": size_hint,
1707            }),
1708        };
1709        let key = crate::core::projection::ProjectionKey {
1710            task_id: task_id.to_string(),
1711            run_id: None,
1712            step: Some(canonical_agent.clone()),
1713            path: None,
1714        };
1715        let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
1716            root,
1717            (*placement).clone(),
1718        );
1719        if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
1720            tracing::warn!(
1721                %task_id,
1722                agent = %producer_agent,
1723                canonical = %canonical_agent,
1724                error = %err,
1725                "submit-time projection sink: file materialize failed (fail-open)"
1726            );
1727        }
1728    }
1729
1730    /// Snapshot the entire output tail for a given `(task_id, attempt)`.
1731    /// Used by the dispatch path when pulling `Final`, and by observers
1732    /// reading the trace.
1733    pub async fn output_tail(
1734        &self,
1735        task_id: &StepId,
1736        attempt: u32,
1737    ) -> Vec<crate::worker::output::OutputEvent> {
1738        let key = (task_id.clone(), attempt);
1739        self.with_state("output_tail", move |s| {
1740            s.output_store.get(&key).cloned().unwrap_or_default()
1741        })
1742        .await
1743        .unwrap_or_default()
1744    }
1745
1746    /// Record an interim `last_result` for `task_id` without changing its
1747    /// `status`. Distinct from the terminal `Final` output event handled
1748    /// through `submit_output` / `dispatch_attempt_with`.
1749    pub async fn post_result(
1750        &self,
1751        token: &CapToken,
1752        task_id: &StepId,
1753        result: Value,
1754    ) -> Result<(), EngineError> {
1755        self.verify_token_for_task(token, Verb::PostResult, task_id)
1756            .await?;
1757        let task_id = task_id.clone();
1758        let result_clone = result.clone();
1759        self.with_state("post_result", move |s| {
1760            let task = s
1761                .tasks
1762                .get_mut(&task_id)
1763                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1764            task.last_result = Some(result_clone);
1765            task.updated_at = now_unix();
1766            Ok::<(), EngineError>(())
1767        })
1768        .await??;
1769        Ok(())
1770    }
1771
1772    /// Store a named resource value, retrievable later via `fetch_data`.
1773    /// No token is required — this is a server-side/admin-style setter
1774    /// (mirrors `bake_worker_system_prompt`).
1775    pub async fn set_resource(
1776        &self,
1777        key: impl Into<String>,
1778        value: Value,
1779    ) -> Result<(), EngineError> {
1780        let key = key.into();
1781        self.with_state("set_resource", move |s| {
1782            s.resources.insert(key, value);
1783        })
1784        .await?;
1785        Ok(())
1786    }
1787
1788    // ═══════════════════════════════════════════════════════════════════════
1789    // Senior suspend / resume
1790    // ═══════════════════════════════════════════════════════════════════════
1791
1792    /// Ask a question of the Senior, mark the task `Suspended`, and
1793    /// return a `ResumeKey`. The suspended state persists until another
1794    /// task calls `resume(key, answer)`.
1795    ///
1796    /// Resume-side waiting is `Notify`-based, so a caller (typically
1797    /// MainAI) can detach, reattach from a different process, and still
1798    /// pull the answer out via `await_resume(key, timeout)` — the answer
1799    /// is stored inside `EngineState`.
1800    pub async fn query_senior(
1801        &self,
1802        token: &CapToken,
1803        task_id: &StepId,
1804        question: Value,
1805    ) -> Result<ResumeKey, EngineError> {
1806        self.verify_token(token, Verb::QuerySenior).await?;
1807        let task_id = task_id.clone();
1808        let key = ResumeKey::for_senior(&task_id);
1809        let task_notify = self
1810            .with_state("query_senior.notify_ensure", |s| {
1811                s.ensure_task_notify(&task_id)
1812            })
1813            .await?;
1814
1815        let key_clone = key.clone();
1816        let task_id_inner = task_id.clone();
1817        let question_clone = question.clone();
1818        self.with_state("query_senior.suspend", move |s| {
1819            let task = s
1820                .tasks
1821                .get_mut(&task_id_inner)
1822                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
1823            task.status = TaskStatus::Suspended;
1824            task.suspended_on = Some(key_clone.clone());
1825            task.updated_at = now_unix();
1826            s.pending_resumes
1827                .insert(key_clone.clone(), ResumePending::new());
1828            s.push_event(Event::SeniorQueried {
1829                task_id: task_id_inner.clone(),
1830                question: question_clone.clone(),
1831            });
1832            s.push_event(Event::TaskSuspended {
1833                task_id: task_id_inner.clone(),
1834                key: key_clone.clone(),
1835            });
1836            Ok::<(), EngineError>(())
1837        })
1838        .await??;
1839
1840        // Notify callers waiting for a task status change (Running → Suspended).
1841        task_notify.notify_waiters();
1842
1843        let _ = self
1844            .inner
1845            .event_tx
1846            .send(Event::SeniorQueried { task_id, question });
1847        Ok(key)
1848    }
1849
1850    /// Store the answer for a `ResumeKey` in `EngineState` and wake the
1851    /// waiting caller via `Notify`. Also flips the suspended task's
1852    /// status back to `Running` and fires the per-task notifier.
1853    pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
1854        let answer_for_state = answer.clone();
1855        let answer_for_event = answer.clone();
1856        let key_clone = key.clone();
1857        let (notify, task_notify, task_id_opt) = self
1858            .with_state("resume.set", move |s| {
1859                let pending = s
1860                    .pending_resumes
1861                    .get_mut(&key_clone)
1862                    .ok_or(EngineError::ResumeKeyNotFound)?;
1863                pending.answer = Some(answer_for_state);
1864                let notify = pending.notify.clone();
1865
1866                let task_id = s
1867                    .tasks
1868                    .iter()
1869                    .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
1870                    .map(|(id, _)| id.clone());
1871
1872                let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
1873
1874                if let Some(tid) = &task_id {
1875                    if let Some(task) = s.tasks.get_mut(tid) {
1876                        task.suspended_on = None;
1877                        task.status = TaskStatus::Running;
1878                        task.updated_at = now_unix();
1879                    }
1880                    s.push_event(Event::TaskResumed {
1881                        task_id: tid.clone(),
1882                        key: key_clone.clone(),
1883                    });
1884                    s.push_event(Event::SeniorAnswered {
1885                        task_id: tid.clone(),
1886                        answer: answer_for_event.clone(),
1887                    });
1888                }
1889                Ok::<_, EngineError>((notify, task_notify, task_id))
1890            })
1891            .await??;
1892
1893        // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
1894        notify.notify_waiters();
1895        if let Some(n) = task_notify {
1896            n.notify_waiters();
1897        }
1898
1899        if let Some(tid) = task_id_opt {
1900            let _ = self
1901                .inner
1902                .event_tx
1903                .send(Event::TaskResumed { task_id: tid, key });
1904        }
1905        Ok(())
1906    }
1907
1908    /// Wait for the resume answer. Even if the caller (an Operator)
1909    /// detached and reattached, the answer is available immediately here
1910    /// — if it was already stored, this returns without waiting on the
1911    /// notifier.
1912    ///
1913    /// `timeout = Duration::ZERO` performs an instant check without
1914    /// waiting.
1915    pub async fn await_resume(
1916        &self,
1917        key: ResumeKey,
1918        timeout: Duration,
1919    ) -> Result<Value, EngineError> {
1920        // (1) Under the lock: clone the notify handle and check for an existing answer.
1921        let key_clone = key.clone();
1922        let (notify, existing) = self
1923            .with_state("await_resume.snapshot", move |s| {
1924                let pending = s
1925                    .pending_resumes
1926                    .get(&key_clone)
1927                    .ok_or(EngineError::ResumeKeyNotFound)?;
1928                Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
1929            })
1930            .await??;
1931
1932        // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
1933        if let Some(v) = existing {
1934            return Ok(v);
1935        }
1936
1937        // (3) Outside the lock: wait on the notify with a timeout.
1938        if timeout.is_zero() {
1939            return Err(EngineError::PollTimeout);
1940        }
1941        let waited = tokio::time::timeout(timeout, notify.notified()).await;
1942        if waited.is_err() {
1943            return Err(EngineError::PollTimeout);
1944        }
1945
1946        // (4) Under the lock: re-read the answer (should be present now that we were notified).
1947        let key_clone = key.clone();
1948        self.with_state("await_resume.read", move |s| {
1949            let pending = s
1950                .pending_resumes
1951                .get(&key_clone)
1952                .ok_or(EngineError::ResumeKeyNotFound)?;
1953            pending
1954                .answer
1955                .clone()
1956                .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
1957        })
1958        .await?
1959    }
1960
1961    // ═══════════════════════════════════════════════════════════════════════
1962    // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
1963    // ═══════════════════════════════════════════════════════════════════════
1964
1965    /// Wait until the task's status **transitions to terminal or
1966    /// `Suspended`**, then return the latest `TaskState`. Returns
1967    /// immediately if the task is already in a terminal state.
1968    /// Exceeding the timeout returns `EngineError::PollTimeout`.
1969    ///
1970    /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
1971    /// (no wait). Larger holds — tens of minutes up to days — are fine;
1972    /// the wait state is kept in memory inside the engine and does not
1973    /// degrade.
1974    pub async fn poll_task(
1975        &self,
1976        token: &CapToken,
1977        task_id: &StepId,
1978        hold: Duration,
1979    ) -> Result<TaskState, EngineError> {
1980        self.verify_token_for_task(token, Verb::PollTask, task_id)
1981            .await?;
1982        let task_id_inner = task_id.clone();
1983
1984        // (1) Under the lock: take a snapshot and clone task_notify.
1985        let (state, notify) = self
1986            .with_state("poll_task.snapshot", move |s| {
1987                let task = s
1988                    .tasks
1989                    .get(&task_id_inner)
1990                    .cloned()
1991                    .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
1992                let notify = s.ensure_task_notify(&task_id_inner);
1993                Ok::<_, EngineError>((task, notify))
1994            })
1995            .await??;
1996
1997        // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
1998        if matches!(
1999            state.status,
2000            TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
2001        ) {
2002            return Ok(state);
2003        }
2004        if hold.is_zero() {
2005            return Ok(state);
2006        }
2007
2008        // (3) Outside the lock: wait on Notify with a timeout.
2009        let waited = tokio::time::timeout(hold, notify.notified()).await;
2010        if waited.is_err() {
2011            return Err(EngineError::PollTimeout);
2012        }
2013
2014        // (4) Under the lock: take a fresh snapshot.
2015        let task_id_inner = task_id.clone();
2016        self.with_state("poll_task.reread", move |s| {
2017            s.tasks
2018                .get(&task_id_inner)
2019                .cloned()
2020                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
2021        })
2022        .await?
2023    }
2024
2025    // ═══════════════════════════════════════════════════════════════════════
2026    // Background: heartbeat miss → detach loop
2027    // ═══════════════════════════════════════════════════════════════════════
2028
2029    /// Background loop that scans sessions every `heartbeat_interval` and
2030    /// flips `attached = false` on any session whose `last_seen` exceeds
2031    /// `heartbeat_miss_threshold * interval`.
2032    ///
2033    /// The tasks themselves are kept (assuming
2034    /// `keepalive_on_idle = true`), so another client can reattach with
2035    /// the same token and resume immediately. Dropping the returned
2036    /// `JoinHandle` does not stop the loop — the handle exists so callers
2037    /// who want to abort can hold onto it.
2038    pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
2039        let engine = self.clone();
2040        let cfg = self.inner.cfg.long_hold.clone();
2041        let interval = cfg.heartbeat_interval;
2042        let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
2043
2044        tokio::spawn(async move {
2045            let mut ticker = tokio::time::interval(interval);
2046            ticker.tick().await; // first tick is immediate
2047            loop {
2048                ticker.tick().await;
2049                let now = now_unix();
2050                let detached = engine
2051                    .with_state("detach_loop.scan", |s| {
2052                        let mut detached = Vec::new();
2053                        for (sid, sess) in s.sessions.iter_mut() {
2054                            if !sess.attached {
2055                                continue;
2056                            }
2057                            if now.saturating_sub(sess.last_seen) >= miss_secs {
2058                                sess.attached = false;
2059                                detached.push(sid.clone());
2060                            }
2061                        }
2062                        for sid in &detached {
2063                            s.push_event(Event::SessionDetached {
2064                                session_id: sid.clone(),
2065                            });
2066                        }
2067                        detached
2068                    })
2069                    .await
2070                    .unwrap_or_default();
2071                for sid in detached {
2072                    let _ = engine
2073                        .inner
2074                        .event_tx
2075                        .send(Event::SessionDetached { session_id: sid });
2076                }
2077            }
2078        })
2079    }
2080
2081    /// Helper: wake a task whose status has changed. Called from the
2082    /// method body outside the lock.
2083    async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
2084        let task_id = task_id.clone();
2085        let notify_opt = self
2086            .with_state("wake_task.get_notify", move |s| {
2087                s.task_notifies.get(&task_id).cloned()
2088            })
2089            .await?;
2090        if let Some(n) = notify_opt {
2091            n.notify_waiters();
2092        }
2093        Ok(())
2094    }
2095}
2096
2097// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
2098#[cfg(test)]
2099mod token_fingerprint_store_tests {
2100    use super::*;
2101
2102    /// A token that was never attached fails verify with a `TokenNotFound`
2103    /// that carries the fingerprint — never the nonce. The error string can
2104    /// surface in HTTP error bodies, so this is the secret-hygiene contract.
2105    #[tokio::test]
2106    async fn verify_unknown_token_reports_fingerprint_not_nonce() {
2107        let engine = Engine::new(EngineCfg::default());
2108        // Signed by the engine's own signer (sig passes) but never inserted
2109        // into the store — verify must fail at step (4), the store lookup.
2110        let token = engine.signer().session(
2111            "ghost",
2112            Role::Operator,
2113            vec!["*".into()],
2114            Duration::from_secs(60),
2115        );
2116        let err = engine
2117            .verify_token(&token, Verb::ReadTaskState)
2118            .await
2119            .expect_err("token is not in the store");
2120        let msg = err.to_string();
2121        assert!(
2122            msg.contains(&token.fingerprint()),
2123            "error must carry the fingerprint: {msg}"
2124        );
2125        assert!(
2126            !msg.contains(&token.nonce),
2127            "error must not leak the nonce: {msg}"
2128        );
2129    }
2130
2131    /// attach → verify → heartbeat → detach all resolve the session /
2132    /// token record through fingerprint keys (mint/verify lifecycle
2133    /// regression guard for the issue #14 key migration).
2134    #[tokio::test]
2135    async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
2136        let engine = Engine::new(EngineCfg::default());
2137        let token = engine
2138            .attach("op-1", Role::Operator, Duration::from_secs(60))
2139            .await
2140            .expect("attach");
2141        engine
2142            .verify_token(&token, Verb::ReadTaskState)
2143            .await
2144            .expect("verify consumes via fp key");
2145        engine
2146            .heartbeat(&token)
2147            .await
2148            .expect("heartbeat finds the session by fp");
2149        engine
2150            .detach(&token)
2151            .await
2152            .expect("detach finds the session by fp");
2153    }
2154}
2155
2156// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
2157//
2158// Regression coverage for the "explicit Automate is indistinguishable from
2159// unspecified" defect: `OperatorSession.operator_kind` (and the
2160// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
2161// so `Some(Automate)` is an explicit Runtime Global request that must
2162// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
2163// the real `resolve_operator_info` cascade path (not just
2164// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
2165// exactly as `TaskLaunchService::launch` does.
2166#[cfg(test)]
2167mod resolve_operator_info_runtime_global_tests {
2168    use super::*;
2169
2170    async fn attach_and_resolve(
2171        runtime_global: Option<OperatorKind>,
2172        bp_global: Option<OperatorKind>,
2173    ) -> OperatorInfo {
2174        let engine = Engine::new(EngineCfg::default());
2175        let token = engine
2176            .attach_with_ids(
2177                "ut-op",
2178                Role::Operator,
2179                Duration::from_secs(30),
2180                runtime_global,
2181                None,
2182                None,
2183                None,
2184                HashMap::new(),
2185                HashMap::new(),
2186                bp_global,
2187            )
2188            .await
2189            .expect("attach_with_ids ok");
2190        let session = engine
2191            .with_state("test.find_session", |s| {
2192                s.sessions
2193                    .values()
2194                    .find(|sess| sess.token_fp == token.fingerprint())
2195                    .cloned()
2196            })
2197            .await
2198            .expect("with_state ok")
2199            .expect("session present after attach_with_ids");
2200        engine.resolve_operator_info(&session, "agent-x").await
2201    }
2202
2203    #[tokio::test]
2204    async fn explicit_some_automate_outranks_bp_global_main_ai() {
2205        // Runtime Global explicitly requests Automate; bp_global is MainAi.
2206        // The explicit `Some(Automate)` must win — this is exactly the case
2207        // the old `== OperatorKind::default()` convention got wrong (it
2208        // could not tell "explicitly Automate" from "unspecified" and would
2209        // have let `bp_global` (MainAi) take over instead).
2210        let info =
2211            attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
2212        assert_eq!(
2213            info.kind,
2214            OperatorKind::Automate,
2215            "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
2216        );
2217    }
2218
2219    #[tokio::test]
2220    async fn none_lets_bp_global_main_ai_win() {
2221        // Runtime Global left unspecified (`None`); bp_global is MainAi.
2222        // With nothing more specific set, `bp_global` must decide.
2223        let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
2224        assert_eq!(
2225            info.kind,
2226            OperatorKind::MainAi,
2227            "None runtime_global must let bp_global MainAi win"
2228        );
2229    }
2230}
2231
2232/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
2233/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
2234/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
2235/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
2236/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
2237/// fails the spawn (only the ctx snapshot matters here).
2238#[cfg(test)]
2239mod dispatch_attempt_with_run_id_tests {
2240    use super::*;
2241    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
2242    use crate::worker::Worker;
2243    use std::sync::Mutex as StdMutex;
2244
2245    struct CtxProbe {
2246        seen: Arc<StdMutex<Option<Ctx>>>,
2247    }
2248
2249    #[async_trait::async_trait]
2250    impl SpawnerAdapter for CtxProbe {
2251        async fn spawn(
2252            &self,
2253            _engine: &Engine,
2254            ctx: &Ctx,
2255            _task_id: StepId,
2256            _attempt: u32,
2257            _token: CapToken,
2258        ) -> Result<Box<dyn Worker>, SpawnError> {
2259            *self.seen.lock().unwrap() = Some(ctx.clone());
2260            Err(SpawnError::Internal("probe stop".into()))
2261        }
2262    }
2263
2264    async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
2265        let engine = Engine::new(EngineCfg::default());
2266        let token = engine
2267            .attach("ut-op", Role::Operator, Duration::from_secs(30))
2268            .await
2269            .expect("attach");
2270        let tid = engine
2271            .start_task(
2272                &token,
2273                TaskSpec {
2274                    agent: "probe".into(),
2275                    initial_directive: "hi".into(),
2276                    step_ctx: None,
2277                },
2278            )
2279            .await
2280            .expect("start_task");
2281        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2282        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2283        // The probe always errors the spawn (`SpawnError::Internal`); we
2284        // only care about the `Ctx` snapshot it captured, so the dispatch
2285        // outcome itself (`Err`) is discarded.
2286        let _ = engine
2287            .dispatch_attempt_with(&token, &tid, &spawner, run_id)
2288            .await;
2289        let captured = seen.lock().unwrap().clone();
2290        captured.expect("inner ctx captured")
2291    }
2292
2293    #[tokio::test]
2294    async fn run_id_lands_in_ctx_meta_runtime_when_some() {
2295        let run_id = RunId::new();
2296        let observed = dispatch_with_probe(Some(&run_id)).await;
2297        assert_eq!(
2298            observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
2299            Some(run_id.as_str()),
2300            "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
2301        );
2302    }
2303
2304    #[tokio::test]
2305    async fn run_id_key_absent_when_none() {
2306        let observed = dispatch_with_probe(None).await;
2307        assert!(
2308            !observed.meta.runtime.contains_key("run_id"),
2309            "no run_id key must be injected when dispatch_attempt_with is called with None"
2310        );
2311    }
2312}
2313
2314/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
2315/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
2316/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
2317/// caching it once at `start_task`), so a retry (attempt 2) carries it
2318/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
2319#[cfg(test)]
2320mod dispatch_attempt_with_step_ctx_tests {
2321    use super::*;
2322    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
2323    use crate::worker::Worker;
2324    use std::sync::Mutex as StdMutex;
2325
2326    struct CtxProbe {
2327        seen: Arc<StdMutex<Option<Ctx>>>,
2328    }
2329
2330    #[async_trait::async_trait]
2331    impl SpawnerAdapter for CtxProbe {
2332        async fn spawn(
2333            &self,
2334            _engine: &Engine,
2335            ctx: &Ctx,
2336            _task_id: StepId,
2337            _attempt: u32,
2338            _token: CapToken,
2339        ) -> Result<Box<dyn Worker>, SpawnError> {
2340            *self.seen.lock().unwrap() = Some(ctx.clone());
2341            Err(SpawnError::Internal("probe stop".into()))
2342        }
2343    }
2344
2345    #[tokio::test]
2346    async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
2347        let engine = Engine::new(EngineCfg::default());
2348        let token = engine
2349            .attach("ut-op", Role::Operator, Duration::from_secs(30))
2350            .await
2351            .expect("attach");
2352        let tid = engine
2353            .start_task(
2354                &token,
2355                TaskSpec {
2356                    agent: "probe".into(),
2357                    initial_directive: "hi".into(),
2358                    step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
2359                },
2360            )
2361            .await
2362            .expect("start_task");
2363        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2364        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2365
2366        // The probe always errors the spawn; only the ctx snapshot matters.
2367        let _ = engine
2368            .dispatch_attempt_with(&token, &tid, &spawner, None)
2369            .await;
2370        let first = seen
2371            .lock()
2372            .unwrap()
2373            .clone()
2374            .expect("attempt 1 ctx captured");
2375        assert_eq!(
2376            first.meta.runtime.get(STEP_CTX_KEY),
2377            Some(&serde_json::json!({ "work_dir": "/step" })),
2378            "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
2379        );
2380
2381        let _ = engine
2382            .dispatch_attempt_with(&token, &tid, &spawner, None)
2383            .await;
2384        let second = seen
2385            .lock()
2386            .unwrap()
2387            .clone()
2388            .expect("attempt 2 ctx captured");
2389        assert_eq!(
2390            second.meta.runtime.get(STEP_CTX_KEY),
2391            Some(&serde_json::json!({ "work_dir": "/step" })),
2392            "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
2393        );
2394    }
2395
2396    #[tokio::test]
2397    async fn step_ctx_key_absent_when_none() {
2398        let engine = Engine::new(EngineCfg::default());
2399        let token = engine
2400            .attach("ut-op", Role::Operator, Duration::from_secs(30))
2401            .await
2402            .expect("attach");
2403        let tid = engine
2404            .start_task(
2405                &token,
2406                TaskSpec {
2407                    agent: "probe".into(),
2408                    initial_directive: "hi".into(),
2409                    step_ctx: None,
2410                },
2411            )
2412            .await
2413            .expect("start_task");
2414        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
2415        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
2416        let _ = engine
2417            .dispatch_attempt_with(&token, &tid, &spawner, None)
2418            .await;
2419        let observed = seen.lock().unwrap().clone().expect("ctx captured");
2420        assert!(
2421            !observed.meta.runtime.contains_key(STEP_CTX_KEY),
2422            "no step_ctx key must be injected when TaskSpec.step_ctx is None"
2423        );
2424    }
2425}
2426
2427// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
2428#[cfg(test)]
2429mod initial_directive_value_passthrough_tests {
2430    use super::*;
2431
2432    async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
2433        let engine = Engine::new(EngineCfg::default());
2434        let op_token = engine
2435            .attach("ut-op", Role::Operator, Duration::from_secs(30))
2436            .await
2437            .expect("attach");
2438        let task_id = engine
2439            .start_task(
2440                &op_token,
2441                TaskSpec {
2442                    agent: "planner".to_string(),
2443                    initial_directive,
2444                    step_ctx: None,
2445                },
2446            )
2447            .await
2448            .expect("start_task");
2449        (engine, op_token, task_id)
2450    }
2451
2452    /// Mint + register a `Role::Worker` token the same way
2453    /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
2454    async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
2455        let worker_token = engine.signer().session(
2456            format!("worker-of-{task_id}"),
2457            Role::Worker,
2458            vec!["*".into()],
2459            Duration::from_secs(600),
2460        );
2461        let fp = worker_token.fingerprint();
2462        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
2463        engine
2464            .with_state("test.mint_worker", move |s| {
2465                s.tokens.insert(fp, record);
2466            })
2467            .await
2468            .expect("mint worker token");
2469        worker_token
2470    }
2471
2472    /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
2473    /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
2474    /// Object seed must round-trip through `start_task` /
2475    /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
2476    /// JSON-stringified `Value::String`.
2477    #[tokio::test]
2478    async fn object_seed_passes_through_task_spec_unchanged() {
2479        let seed = serde_json::json!({"key": "value"});
2480        let (engine, token, task_id) = seeded_engine(seed.clone()).await;
2481        let state = engine
2482            .read_task_state(&token, &task_id)
2483            .await
2484            .expect("read_task_state");
2485        assert_eq!(
2486            state.spec.initial_directive, seed,
2487            "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
2488        );
2489    }
2490
2491    /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
2492    /// an Object seed stays a `Value::Object` and is not stringified in
2493    /// the engine layer. The Worker HTTP boundary
2494    /// (`fetch_worker_payload*`) is what performs the render down to a
2495    /// JSON literal `String` for `WorkerPayload.prompt`.
2496    #[tokio::test]
2497    async fn object_seed_passes_through_fetch_prompt_as_value() {
2498        let seed = serde_json::json!({"key": "value"});
2499        let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
2500        let worker_token = mint_worker_token(&engine, &task_id).await;
2501        let prompt = engine
2502            .fetch_prompt(&worker_token, &task_id)
2503            .await
2504            .expect("fetch_prompt");
2505        assert_eq!(
2506            prompt, seed,
2507            "fetch_prompt must return the raw Object Value, not a stringified copy"
2508        );
2509    }
2510
2511    /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
2512    /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
2513    /// (JSON-literal shape for non-strings). Verifies the boundary render
2514    /// stays intact for an Object seed.
2515    #[tokio::test]
2516    async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
2517        let seed = serde_json::json!({"key": "value"});
2518        let (engine, _token, task_id) = seeded_engine(seed).await;
2519        let worker_token = mint_worker_token(&engine, &task_id).await;
2520        let payload = engine
2521            .fetch_worker_payload(&worker_token, &task_id)
2522            .await
2523            .expect("fetch_worker_payload");
2524        assert_eq!(
2525            payload.prompt, r#"{"key":"value"}"#,
2526            "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
2527        );
2528    }
2529
2530    /// A `String` seed is unaffected — still passes through verbatim, both
2531    /// as the `TaskSpec.initial_directive` `Value` and as the Worker
2532    /// `fetch_prompt` return (issue #18 Invariant 2).
2533    #[tokio::test]
2534    async fn string_seed_passes_through_unchanged() {
2535        let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
2536        let state = engine
2537            .read_task_state(&token, &task_id)
2538            .await
2539            .expect("read_task_state");
2540        assert_eq!(
2541            state.spec.initial_directive,
2542            serde_json::json!("do the thing")
2543        );
2544        let worker_token = mint_worker_token(&engine, &task_id).await;
2545        let prompt = engine
2546            .fetch_prompt(&worker_token, &task_id)
2547            .await
2548            .expect("fetch_prompt");
2549        assert_eq!(prompt, serde_json::json!("do the thing"));
2550    }
2551}
2552
2553/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
2554/// submit-time projection sink (`Engine::materialize_final_submission`) —
2555/// the Data-plane `OutputStore` dual-write plus the
2556/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
2557/// the subtask-4 Tests this module covers inline on each test.
2558#[cfg(test)]
2559mod submit_time_projection_sink_tests {
2560    use super::*;
2561    use crate::core::agent_context::AgentContextView;
2562    use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
2563
2564    /// Starts a task under `agent`, returning `(engine, op_token, task_id,
2565    /// worker_token)` — same helper shape as the sibling test modules
2566    /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
2567    /// `mint_worker_token`), duplicated locally per this file's
2568    /// established per-module convention.
2569    async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
2570        let engine = Engine::new(EngineCfg::default());
2571        let op_token = engine
2572            .attach("ut-op", Role::Operator, Duration::from_secs(30))
2573            .await
2574            .expect("attach");
2575        let task_id = engine
2576            .start_task(
2577                &op_token,
2578                TaskSpec {
2579                    agent: agent.to_string(),
2580                    initial_directive: Value::String("go".into()),
2581                    step_ctx: None,
2582                },
2583            )
2584            .await
2585            .expect("start_task");
2586        let worker_token = engine.signer().session(
2587            format!("worker-of-{task_id}"),
2588            Role::Worker,
2589            vec!["*".into()],
2590            Duration::from_secs(600),
2591        );
2592        let fp = worker_token.fingerprint();
2593        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
2594        engine
2595            .with_state("test.mint_worker", move |s| {
2596                s.tokens.insert(fp, record);
2597            })
2598            .await
2599            .expect("mint worker token");
2600        (engine, op_token, task_id, worker_token)
2601    }
2602
2603    /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
2604    /// the same snapshot `AgentContextMiddleware` writes at spawn time
2605    /// (see its module doc), stood up here without the full spawner
2606    /// stack so these tests can exercise `submit_output` in isolation.
2607    async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
2608        let task_id = task_id.clone();
2609        let work_dir = work_dir.to_string();
2610        engine
2611            .with_state("test.seed_agent_context", move |s| {
2612                s.agent_ctx.insert(
2613                    (task_id, attempt),
2614                    crate::core::state::AgentCtxEntry {
2615                        view: AgentContextView {
2616                            work_dir: Some(work_dir),
2617                            ..Default::default()
2618                        },
2619                        policy: Default::default(),
2620                    },
2621                );
2622            })
2623            .await
2624            .expect("seed agent_ctx");
2625    }
2626
2627    /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
2628    /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
2629    /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
2630    /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
2631    /// fallback in both directions.
2632    async fn seed_agent_context_roots(
2633        engine: &Engine,
2634        task_id: &StepId,
2635        attempt: u32,
2636        work_dir: Option<&str>,
2637        project_root: Option<&str>,
2638    ) {
2639        let task_id = task_id.clone();
2640        let work_dir = work_dir.map(str::to_string);
2641        let project_root = project_root.map(str::to_string);
2642        engine
2643            .with_state("test.seed_agent_context_roots", move |s| {
2644                s.agent_ctx.insert(
2645                    (task_id, attempt),
2646                    crate::core::state::AgentCtxEntry {
2647                        view: AgentContextView {
2648                            work_dir,
2649                            project_root,
2650                            ..Default::default()
2651                        },
2652                        policy: Default::default(),
2653                    },
2654                );
2655            })
2656            .await
2657            .expect("seed agent_ctx");
2658    }
2659
2660    /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
2661    /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
2662    /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
2663    /// these tests can exercise a declared `ProjectionPlacement` without
2664    /// driving a real `Compiler::compile`.
2665    async fn seed_projection_placement(
2666        engine: &Engine,
2667        task_id: &StepId,
2668        placement: crate::core::projection_placement::ProjectionPlacement,
2669    ) {
2670        let task_id = task_id.clone();
2671        let placement = Arc::new(placement);
2672        engine
2673            .with_state("test.seed_projection_placement", move |s| {
2674                s.projection_placements.insert(task_id, placement);
2675            })
2676            .await
2677            .expect("seed projection_placements");
2678    }
2679
2680    /// GH #23 subtask-2: builds a fixture
2681    /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
2682    /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
2683    /// `EngineState.step_namings` for `task_id` — the same snapshot
2684    /// `EngineDispatcher::dispatch` stashes at dispatch time
2685    /// (`blueprint.rs`'s "construct once, read many" contract), stood up
2686    /// here without the full Blueprint-compile path so these tests can
2687    /// exercise the canonical-sink resolution in isolation.
2688    async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
2689        use crate::blueprint::{
2690            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
2691            CompilerHints, CompilerStrategy,
2692        };
2693        use crate::core::step_naming::StepNaming;
2694        use mlua_flow_ir::{Expr, Node};
2695
2696        let flow = Node::Step {
2697            ref_: producer.to_string(),
2698            in_: Expr::Path {
2699                at: "$.in".to_string(),
2700            },
2701            out: Expr::Path {
2702                at: format!("$.{producer}_out"),
2703            },
2704        };
2705        let bp = Blueprint {
2706            schema_version: current_schema_version(),
2707            id: "sink-canonical-ut".into(),
2708            flow,
2709            agents: vec![AgentDef {
2710                name: producer.to_string(),
2711                kind: AgentKind::RustFn,
2712                spec: serde_json::json!({ "fn_id": producer }),
2713                profile: None,
2714                meta: Some(AgentMeta {
2715                    projection_name: Some(canonical.to_string()),
2716                    ..Default::default()
2717                }),
2718            }],
2719            operators: vec![],
2720            metas: vec![],
2721            hints: CompilerHints::default(),
2722            strategy: CompilerStrategy::default(),
2723            metadata: BlueprintMetadata::default(),
2724            spawner_hints: Default::default(),
2725            default_agent_kind: AgentKind::Operator,
2726            default_operator_kind: None,
2727            default_init_ctx: None,
2728            default_agent_ctx: None,
2729            default_context_policy: None,
2730            projection_placement: None,
2731        };
2732        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
2733        assert!(warnings.is_empty(), "single-step fixture has no collisions");
2734        let naming = Arc::new(naming);
2735        let task_id = task_id.clone();
2736        engine
2737            .with_state("test.seed_step_naming", move |s| {
2738                s.step_namings.insert(task_id, naming);
2739            })
2740            .await
2741            .expect("seed step_namings");
2742    }
2743
2744    fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
2745        crate::worker::output::OutputEvent::Final {
2746            content: crate::worker::output::ContentRef::Inline { value },
2747            ok,
2748        }
2749    }
2750
2751    /// Subtask 4 Test #2: `submit_output`'s `Final` writes
2752    /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
2753    /// the submitted value.
2754    #[tokio::test]
2755    async fn submit_output_final_materializes_file_when_work_dir_resolved() {
2756        let dir = tempfile::TempDir::new().unwrap();
2757        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2758        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
2759
2760        engine
2761            .submit_output(
2762                &worker_token,
2763                &task_id,
2764                1,
2765                final_event(serde_json::json!({"plan": "do it"}), true),
2766            )
2767            .await
2768            .expect("submit_output");
2769
2770        let expected_file = dir
2771            .path()
2772            .join("workspace/tasks")
2773            .join(task_id.as_str())
2774            .join("ctx/planner.md");
2775        assert!(
2776            expected_file.exists(),
2777            "materialized submission file missing at {expected_file:?}"
2778        );
2779        let body = std::fs::read_to_string(expected_file).unwrap();
2780        assert!(body.contains(r#""plan": "do it""#), "body: {body}");
2781    }
2782
2783    /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
2784    /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
2785    /// fail-open, no file.
2786    #[tokio::test]
2787    async fn submit_output_final_skips_file_when_root_unresolved() {
2788        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2789        // No seed_agent_context call — root is unresolved.
2790
2791        let result = engine
2792            .submit_output(
2793                &worker_token,
2794                &task_id,
2795                1,
2796                final_event(serde_json::json!("hi"), true),
2797            )
2798            .await;
2799        assert!(
2800            result.is_ok(),
2801            "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
2802        );
2803    }
2804
2805    /// Subtask 4 Test #4 (file half): re-submitting under the same
2806    /// `(task_id, agent)` overwrites the materialized file with the
2807    /// latest value.
2808    #[tokio::test]
2809    async fn resubmit_overwrites_materialized_file_with_latest() {
2810        let dir = tempfile::TempDir::new().unwrap();
2811        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2812        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
2813
2814        engine
2815            .submit_output(
2816                &worker_token,
2817                &task_id,
2818                1,
2819                final_event(serde_json::json!("first"), true),
2820            )
2821            .await
2822            .expect("first submit");
2823        engine
2824            .submit_output(
2825                &worker_token,
2826                &task_id,
2827                1,
2828                final_event(serde_json::json!("second"), true),
2829            )
2830            .await
2831            .expect("second submit");
2832
2833        let expected_file = dir
2834            .path()
2835            .join("workspace/tasks")
2836            .join(task_id.as_str())
2837            .join("ctx/planner.md");
2838        let body = std::fs::read_to_string(expected_file).unwrap();
2839        assert!(body.contains("second"), "body must reflect latest: {body}");
2840        assert!(
2841            !body.contains("first"),
2842            "body must not carry the stale value: {body}"
2843        );
2844    }
2845
2846    /// GH #27 (follow-up to #23): the byte-compat default
2847    /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
2848    /// `project_root` when `work_dir` is absent — the same fallback
2849    /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
2850    /// now performs for every one of the "3 path" call sites, this one
2851    /// exercised at the submit-sink layer.
2852    #[tokio::test]
2853    async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
2854        let dir = tempfile::TempDir::new().unwrap();
2855        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2856        seed_agent_context_roots(
2857            &engine,
2858            &task_id,
2859            1,
2860            None,
2861            Some(&dir.path().to_string_lossy()),
2862        )
2863        .await;
2864
2865        engine
2866            .submit_output(
2867                &worker_token,
2868                &task_id,
2869                1,
2870                final_event(serde_json::json!({"plan": "via project_root"}), true),
2871            )
2872            .await
2873            .expect("submit_output");
2874
2875        let expected_file = dir
2876            .path()
2877            .join("workspace/tasks")
2878            .join(task_id.as_str())
2879            .join("ctx/planner.md");
2880        assert!(
2881            expected_file.exists(),
2882            "materialized submission file missing at {expected_file:?} \
2883             (work_dir absent must fall back to project_root)"
2884        );
2885    }
2886
2887    /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
2888    /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
2889    /// BOTH which root is preferred (project_root wins even though
2890    /// work_dir is also present) AND the target directory layout — proof
2891    /// the submit sink consults the snapshotted resolver rather than a
2892    /// hardcoded layout.
2893    #[tokio::test]
2894    async fn submit_output_final_uses_declared_projection_placement() {
2895        let work_dir = tempfile::TempDir::new().unwrap();
2896        let project_root = tempfile::TempDir::new().unwrap();
2897        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
2898        seed_agent_context_roots(
2899            &engine,
2900            &task_id,
2901            1,
2902            Some(&work_dir.path().to_string_lossy()),
2903            Some(&project_root.path().to_string_lossy()),
2904        )
2905        .await;
2906        seed_projection_placement(
2907            &engine,
2908            &task_id,
2909            crate::core::projection_placement::ProjectionPlacement {
2910                root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
2911                dir_template: "custom/{task_id}/out".to_string(),
2912            },
2913        )
2914        .await;
2915
2916        engine
2917            .submit_output(
2918                &worker_token,
2919                &task_id,
2920                1,
2921                final_event(serde_json::json!({"plan": "via custom placement"}), true),
2922            )
2923            .await
2924            .expect("submit_output");
2925
2926        let expected_file = project_root
2927            .path()
2928            .join("custom")
2929            .join(task_id.as_str())
2930            .join("out/planner.md");
2931        assert!(
2932            expected_file.exists(),
2933            "materialized submission file missing at custom placement target {expected_file:?}"
2934        );
2935        let unexpected_file = work_dir
2936            .path()
2937            .join("workspace/tasks")
2938            .join(task_id.as_str())
2939            .join("ctx/planner.md");
2940        assert!(
2941            !unexpected_file.exists(),
2942            "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
2943        );
2944    }
2945
2946    /// Subtask 4 Invariant 3 / crux requirement #3: when
2947    /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
2948    /// `submit_output`'s `Final` dual-writes into it under
2949    /// `producer_agent = TaskState.spec.agent` — the store becomes
2950    /// queryable via `get_latest_by_name`, independent of whether a root
2951    /// resolved for the file half.
2952    #[tokio::test]
2953    async fn submit_output_final_dual_writes_into_configured_output_store() {
2954        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
2955        let data_store: Arc<dyn crate::store::output::OutputStore> =
2956            Arc::new(InMemoryOutputStore::new());
2957        engine.set_output_store(data_store.clone());
2958
2959        engine
2960            .submit_output(
2961                &worker_token,
2962                &task_id,
2963                1,
2964                final_event(serde_json::json!({"verdict": "pass"}), true),
2965            )
2966            .await
2967            .expect("submit_output");
2968
2969        let record = data_store
2970            .get_latest_by_name("reviewer")
2971            .await
2972            .expect("dual-written record");
2973        match record.event {
2974            OutputEvent::Final { content, ok } => {
2975                assert!(ok);
2976                match content {
2977                    ContentRef::Inline { value } => {
2978                        assert_eq!(value, serde_json::json!({"verdict": "pass"}));
2979                    }
2980                    other => panic!("expected Inline content, got {other:?}"),
2981                }
2982            }
2983            other => panic!("expected Final event, got {other:?}"),
2984        }
2985    }
2986
2987    /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
2988    /// path) triggers the exact same sink as `submit_output` — parity
2989    /// across both worker-submit entry points.
2990    #[tokio::test]
2991    async fn submit_worker_result_trusted_also_triggers_projection_sink() {
2992        let dir = tempfile::TempDir::new().unwrap();
2993        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
2994        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
2995        let data_store: Arc<dyn crate::store::output::OutputStore> =
2996            Arc::new(InMemoryOutputStore::new());
2997        engine.set_output_store(data_store.clone());
2998
2999        engine
3000            .submit_worker_result_trusted(&task_id, 1, serde_json::json!("trusted-value"), true)
3001            .await
3002            .expect("submit_worker_result_trusted");
3003
3004        let expected_file = dir
3005            .path()
3006            .join("workspace/tasks")
3007            .join(task_id.as_str())
3008            .join("ctx/planner.md");
3009        assert!(expected_file.exists());
3010        let record = data_store
3011            .get_latest_by_name("planner")
3012            .await
3013            .expect("dual-written record");
3014        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3015    }
3016
3017    /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
3018    /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
3019    /// `submit_output`'s Final canonical sink — both the Data-plane
3020    /// dual-write name and the materialized file stem resolve to the
3021    /// canonical name, not the raw `producer_agent`.
3022    #[tokio::test]
3023    async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
3024        let dir = tempfile::TempDir::new().unwrap();
3025        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3026        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
3027        seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
3028        let data_store: Arc<dyn crate::store::output::OutputStore> =
3029            Arc::new(InMemoryOutputStore::new());
3030        engine.set_output_store(data_store.clone());
3031
3032        engine
3033            .submit_output(
3034                &worker_token,
3035                &task_id,
3036                1,
3037                final_event(serde_json::json!({"verdict": "pass"}), true),
3038            )
3039            .await
3040            .expect("submit_output");
3041
3042        let record = data_store
3043            .get_latest_by_name("verdict-final")
3044            .await
3045            .expect("dual-written record under canonical name");
3046        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3047        assert!(
3048            data_store.get_latest_by_name("reviewer").await.is_err(),
3049            "raw producer_agent name must not be written once canonical resolves"
3050        );
3051
3052        let expected_file = dir
3053            .path()
3054            .join("workspace/tasks")
3055            .join(task_id.as_str())
3056            .join("ctx/verdict-final.md");
3057        assert!(
3058            expected_file.exists(),
3059            "materialized file stem must be canonical at {expected_file:?}"
3060        );
3061    }
3062
3063    /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
3064    /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
3065    /// defensive fail-open — the canonical sink falls back to the raw
3066    /// `producer_agent`, byte-identical to
3067    /// `submit_output_final_dual_writes_into_configured_output_store`
3068    /// above (which never calls `seed_step_naming`).
3069    #[tokio::test]
3070    async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
3071        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3072        let data_store: Arc<dyn crate::store::output::OutputStore> =
3073            Arc::new(InMemoryOutputStore::new());
3074        engine.set_output_store(data_store.clone());
3075
3076        engine
3077            .submit_output(
3078                &worker_token,
3079                &task_id,
3080                1,
3081                final_event(serde_json::json!({"verdict": "pass"}), true),
3082            )
3083            .await
3084            .expect("submit_output");
3085
3086        let record = data_store
3087            .get_latest_by_name("reviewer")
3088            .await
3089            .expect("fail-open dual-write under raw producer_agent name");
3090        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3091    }
3092
3093    /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
3094    /// resolves the value `submit_output` dual-wrote for this exact
3095    /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
3096    /// cross-Run race (two Runs sharing a producer name never bleed into
3097    /// each other through the Run-scoped accessor).
3098    #[tokio::test]
3099    async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
3100        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
3101        let data_store: Arc<dyn crate::store::output::OutputStore> =
3102            Arc::new(InMemoryOutputStore::new());
3103        engine.set_output_store(data_store.clone());
3104
3105        engine
3106            .submit_output(
3107                &worker_token,
3108                &task_id,
3109                1,
3110                final_event(serde_json::json!({"verdict": "pass"}), true),
3111            )
3112            .await
3113            .expect("submit_output");
3114
3115        let record = data_store
3116            .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
3117            .await
3118            .expect("run-scoped lookup resolves the dual-written record");
3119        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
3120
3121        // A different attempt of the same task must not resolve — the
3122        // Run-scoped lookup does not fall back across attempts.
3123        assert!(
3124            data_store
3125                .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
3126                .await
3127                .is_err(),
3128            "a different attempt must not resolve the same-named record"
3129        );
3130    }
3131}