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    unwrap_skip_marker, wrap_skip_marker, CapTokenRecord, DispatchOutcome, EngineState, Event,
17    EventStream, OperatorSession, ResumeKey, ResumePending, SubmitOutcome, TaskSpec, TaskState,
18    TaskStatus,
19};
20use crate::store::replay::{hash_input_value, ReplayEntry};
21use crate::store::run::RunContext;
22use crate::types::{
23    default_role_verb_table, now_unix, CapToken, Role, RoleVerbGate, RunId, SessionId, StepId,
24    TokenSigner, Verb,
25};
26use crate::worker::adapter::SpawnerAdapter;
27use serde_json::Value;
28use std::collections::HashMap;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31use tokio::sync::{broadcast, Mutex};
32
33/// Process-wide long-running runtime. Cheap to `clone()` — an `Arc`
34/// lives inside.
35#[derive(Clone)]
36pub struct Engine {
37    inner: Arc<EngineInner>,
38}
39
40struct EngineInner {
41    state: Mutex<EngineState>,
42    cfg: EngineCfg,
43    signer: TokenSigner,
44    gate: RoleVerbGate,
45    event_tx: broadcast::Sender<Event>,
46    /// ID-keyed bridge registry (register-by-ID design). `SeniorBridge`
47    /// and `SpawnHook` are registered by ID; sessions bind to those IDs
48    /// only. Persistence stores just the ID, and on reattach the caller
49    /// re-registers under the same ID to restore presence.
50    senior_bridges: tokio::sync::RwLock<HashMap<String, Arc<dyn SeniorBridge>>>,
51    spawn_hooks: tokio::sync::RwLock<HashMap<String, Arc<dyn SpawnHook>>>,
52    /// ID registry for full-spawn Operator backends (backends that take the
53    /// entire spawn via `execute`). Sibling to `senior_bridges` /
54    /// `spawn_hooks`. `OperatorDelegateMiddleware` looks these up via
55    /// `ctx` and, when `kind = MainAi` / `Composite`, bypasses
56    /// `inner.spawn` and calls `operator.execute` instead.
57    operators: tokio::sync::RwLock<HashMap<String, Arc<dyn crate::operator::Operator>>>,
58    /// Base and hint layer factories for the `SpawnerStack`. At
59    /// `service::linker::link` time, `compiled.router` is wrapped with
60    /// the base factories plus the hint factories resolved from
61    /// `blueprint.spawner_hints.layers`. This is the engine-side
62    /// counterpart to the discipline "Flow / Blueprint doesn't spell out
63    /// middleware implementations — it declares the capabilities it needs
64    /// as hint keys".
65    layer_registry: crate::middleware::LayerRegistry,
66    /// Optional Data-plane `OutputStore` backend (subtask-4 / ST2 rework —
67    /// see `submit_output`'s doc). `None` (the default) preserves
68    /// pre-subtask-4 behavior exactly: `submit_output` /
69    /// `submit_worker_result_trusted` only touch the Domain-plane
70    /// `EngineState.output_store` HashMap, same as before this was added.
71    /// `Some` additionally dual-writes every `Final` event into this store
72    /// via [`crate::store::output::OutputStore::append`], making it
73    /// queryable (e.g. by `mlua-swarm-server`'s `GET /v1/tasks/:id/ctx`)
74    /// even for an in-flight run. A plain `std::sync::RwLock` (not
75    /// `tokio::sync::RwLock`) — set once at boot via [`Engine::set_output_store`]
76    /// from a synchronous call site (`mlua-swarm-server`'s router builder),
77    /// then only ever briefly read (clone the `Option<Arc<..>>`, never held
78    /// across an `.await`) from the async submit path.
79    data_store: std::sync::RwLock<Option<Arc<dyn crate::store::output::OutputStore>>>,
80    /// GH #50 (Subtask 2 — runtime plumbing): agent name → declared
81    /// [`mlua_swarm_schema::VerdictContract`], the Engine-side registry
82    /// [`Self::verdict_contract_for_task`] resolves against. Populated via
83    /// [`Self::register_verdict_contracts`] — same sync-`RwLock`,
84    /// set-outside-the-lock idiom as `data_store` above. Empty by default
85    /// (every pre-GH-#50 `Engine`), which is exactly the opt-in "no
86    /// contract declared" state `verdict_contract_for_task` treats as
87    /// `None`. Populated from a live `Compiler::compile`'s
88    /// `CompiledAgentTable.verdict_contracts` output by
89    /// `TaskLaunchService::launch`, immediately after `compiler.compile`
90    /// succeeds — see [`Self::register_verdict_contracts`]'s doc for the
91    /// overwrite semantics of that merge.
92    verdict_contracts: std::sync::RwLock<HashMap<String, mlua_swarm_schema::VerdictContract>>,
93}
94
95/// Renders a `TaskSpec.initial_directive` / `EngineState.prompts`
96/// `Value` down to the `String` shape that string-consuming boundaries
97/// require (issue #18). Strings pass through verbatim; anything else
98/// (Object / Array / Number / Bool / Null) is serde-stringified. This
99/// is the single canonical rendering — the coercion that used to sit
100/// inside `EngineDispatcher::dispatch` moved here and is invoked only
101/// at consumer boundaries: `WorkerPayload.prompt` (HTTP
102/// `/v1/worker/prompt`), `WorkerInvocation.prompt` (in-process
103/// spawners), the subprocess spawner's directive arg/stdin, and the
104/// WS Spawn frame text render (`operator_ws::session`). Everything
105/// upstream (Blueprint dispatch → engine state → `fetch_prompt` →
106/// `Operator::execute`) keeps the `Value` end-to-end.
107pub(crate) fn render_directive_to_string(v: &Value) -> String {
108    match v {
109        Value::String(s) => s.clone(),
110        other => other.to_string(),
111    }
112}
113
114/// Renders a [`crate::worker::output::ContentRef`] down to the `Value` shape
115/// the BP-chain / `DispatchOutcome` consume. `Inline` passes its `value`
116/// through verbatim; `FileRef` is stringified into the same
117/// `{"file_ref", "mime", "size_hint"}` shape `materialize_final_submission`
118/// uses for its own file-materialize projection — one canonical
119/// stringification, not two independently-maintained copies (GH #36 ST1:
120/// shared by both the `Final`-pull and the `Artifact`-parts fold in
121/// [`Engine::dispatch_attempt_with`]'s doc).
122fn content_ref_to_value(content: crate::worker::output::ContentRef) -> Value {
123    match content {
124        crate::worker::output::ContentRef::Inline { value } => value,
125        crate::worker::output::ContentRef::FileRef {
126            path,
127            mime,
128            size_hint,
129        } => serde_json::json!({
130            "file_ref": path.to_string_lossy(),
131            "mime": mime,
132            "size_hint": size_hint,
133        }),
134    }
135}
136
137/// GH #51 — reduces a [`content_ref_to_value`] result down to the `String`
138/// shape the completion-time verdict-contract check compares against a
139/// declared `VerdictContract.values` token set. A `Value::String` unwraps
140/// to its raw contents (no surrounding JSON quotes) — this mirrors the
141/// pre-GH-#51 `check_verdict_contract` (`mlua-swarm-server`'s
142/// `worker.rs`), which always compared the raw submitted body string
143/// directly, never a JSON-stringified copy. Any OTHER `Value` shape
144/// (`Number` / `Object` / `Array` / `Bool` / `Null` — i.e. a `channel:
145/// "body"` contract whose completing value is not a string at all, or a
146/// `FileRef` content whose `content_ref_to_value` projection is an
147/// object) falls back to `Value::to_string()`'s JSON-encoded form: it can
148/// never collide with a plain declared token like `"PASS"`, so it
149/// naturally fails membership — consistent with the "non-string values
150/// under a body contract are violations" rule (issue #51's Proposal).
151fn content_ref_to_comparable_string(content: crate::worker::output::ContentRef) -> String {
152    let value = content_ref_to_value(content);
153    match value {
154        Value::String(s) => s,
155        other => other.to_string(),
156    }
157}
158
159/// [`Engine::dispatch_attempt_with`]'s Final-pull assembly (GH #36 ST1:
160/// named multi-part worker output), factored out as a pure function of the
161/// output-event tail so it is unit-testable without a live `Engine` /
162/// spawner.
163///
164/// Finds the LAST `Final` event in `tail` (mirrors the pre-GH-#36 pull:
165/// "last Final wins" if more than one was ever appended) and folds every
166/// `Artifact` event in the SAME tail WHOSE NAME APPEARS IN `staged_names`
167/// into a `"parts"` object keyed by `Artifact.name` — walked in tail (=
168/// event-append) order, so a name staged more than once within the attempt
169/// is last-write-wins (`Map` insert semantics, not an accumulating list;
170/// `Engine::stage_worker_artifact_trusted`'s doc). `staged_names` is the
171/// WORKER's own opt-in allowlist (`EngineState.worker_artifact_names`'s
172/// doc) — an `Artifact` on the tail whose name is NOT in `staged_names`
173/// (e.g. `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding)
174/// is left alone, exactly as before GH #36; this is what keeps an audited
175/// step's BP-chain value byte-identical when the worker itself never
176/// staged a part.
177///
178/// At least one matching part: the returned value is `{"out": <final
179/// value>, "parts": {<name>: <value>, ...}}`. Zero matching parts: the
180/// returned value is the plain final value, unchanged from the pre-GH-#36
181/// shape — this is the back-compat guarantee, not an incidental default.
182///
183/// `None` when `tail` carries no `Final` at all (the caller's pre-existing
184/// "no Final in output_tail" error path).
185fn fold_final_and_parts(
186    tail: &[crate::worker::output::OutputEvent],
187    staged_names: &[String],
188) -> Option<(Value, bool)> {
189    let (final_content, ok) = tail.iter().rev().find_map(|ev| match ev {
190        crate::worker::output::OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
191        _ => None,
192    })?;
193    let final_value = content_ref_to_value(final_content);
194
195    let mut parts = serde_json::Map::new();
196    for ev in tail {
197        if let crate::worker::output::OutputEvent::Artifact { name, content } = ev {
198            if staged_names.iter().any(|staged| staged == name) {
199                parts.insert(name.clone(), content_ref_to_value(content.clone()));
200            }
201        }
202    }
203
204    let value = if parts.is_empty() {
205        final_value
206    } else {
207        serde_json::json!({ "out": final_value, "parts": Value::Object(parts) })
208    };
209    Some((value, ok))
210}
211
212impl Engine {
213    /// Backwards-compatible constructor that starts the engine without a
214    /// layer registry, preserving the signature already used by ~88
215    /// existing call sites. Use this when automatic middleware wrapping
216    /// at bind time is not needed. Callers such as `mlua-swarm-server` go through
217    /// `new_with_layers(cfg, registry)` to enable the hint-resolution path.
218    pub fn new(cfg: EngineCfg) -> Self {
219        Self::new_with_layers(cfg, crate::middleware::LayerRegistry::new())
220    }
221
222    /// Construct an `Engine` with an explicit `LayerRegistry`, enabling
223    /// hint-resolution: `spawner_hints.layers` declared on a `Blueprint`
224    /// are resolved against this registry when the spawner stack is bound
225    /// at `service::linker::link` time.
226    pub fn new_with_layers(
227        cfg: EngineCfg,
228        layer_registry: crate::middleware::LayerRegistry,
229    ) -> Self {
230        let (event_tx, _) = broadcast::channel(256);
231        let signer = TokenSigner::new(&cfg.token_secret);
232        Self {
233            inner: Arc::new(EngineInner {
234                state: Mutex::new(EngineState::new()),
235                cfg,
236                signer,
237                gate: default_role_verb_table(),
238                event_tx,
239                senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
240                spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
241                operators: tokio::sync::RwLock::new(HashMap::new()),
242                layer_registry,
243                data_store: std::sync::RwLock::new(None),
244                verdict_contracts: std::sync::RwLock::new(HashMap::new()),
245            }),
246        }
247    }
248
249    /// Rebuild this `Engine` with a different `RoleVerbGate`. The gate is
250    /// treated as fixed-at-build-time, so this constructs a fresh
251    /// `EngineInner` (fresh empty `EngineState`) rather than mutating in
252    /// place — mainly a testing convenience for swapping gate rules.
253    pub fn with_gate(self, gate: RoleVerbGate) -> Self {
254        // The gate is fixed at build time — the intent is to build a fresh
255        // instance rather than mutating in place. As a testing convenience we
256        // do allow swapping the inner Arc. Simpler form: just rebuild
257        // Arc<EngineInner>.
258        let inner = Arc::new(EngineInner {
259            state: Mutex::new(EngineState::new()),
260            cfg: self.inner.cfg.clone(),
261            signer: self.inner.signer.clone(),
262            gate,
263            event_tx: self.inner.event_tx.clone(),
264            senior_bridges: tokio::sync::RwLock::new(HashMap::new()),
265            spawn_hooks: tokio::sync::RwLock::new(HashMap::new()),
266            operators: tokio::sync::RwLock::new(HashMap::new()),
267            layer_registry: self.inner.layer_registry.clone(),
268            data_store: std::sync::RwLock::new(None),
269            verdict_contracts: std::sync::RwLock::new(HashMap::new()),
270        });
271        Self { inner }
272    }
273
274    // ═══════════════════════════════════════════════════════════════════════
275    // Accessors. Production code drives execution through compile +
276    // `service::linker::link` + `dispatch_attempt_with(spawner)` inside
277    // `TaskLaunchService`; `Engine` itself is a pure execution surface — it
278    // does not own a BlueprintStore / EnhanceAdapter / Compiler, nor a
279    // global spawner (the spawner is carried per-request, never stashed on
280    // the engine).
281    // ═══════════════════════════════════════════════════════════════════════
282
283    /// Access the `EngineCfg` this engine was built with.
284    pub fn cfg(&self) -> &EngineCfg {
285        &self.inner.cfg
286    }
287
288    /// Expose the internal `LayerRegistry` — used when deriving a
289    /// sub-engine that needs the same registry re-injected. The
290    /// per-request sub-engine in `mlua-swarm-server` reads the parent engine's
291    /// registry through this accessor and passes it to
292    /// `Engine::new_with_layers(cfg, parent.layer_registry().clone())`.
293    pub fn layer_registry(&self) -> &crate::middleware::LayerRegistry {
294        &self.inner.layer_registry
295    }
296
297    /// Access the `TokenSigner` used to mint/verify `CapToken`s.
298    pub fn signer(&self) -> &TokenSigner {
299        &self.inner.signer
300    }
301
302    /// Clone a handle to the process-wide `Event` broadcast sender. Prefer
303    /// `subscribe` for a ready-to-use receiver.
304    pub fn event_tx(&self) -> broadcast::Sender<Event> {
305        self.inner.event_tx.clone()
306    }
307
308    /// Subscribe to the engine's `Event` broadcast stream.
309    pub fn subscribe(&self) -> EventStream {
310        self.inner.event_tx.subscribe()
311    }
312
313    /// Wires the Data-plane [`crate::store::output::OutputStore`] backend
314    /// used by `submit_output` / `submit_worker_result_trusted`'s
315    /// submit-time projection sink (subtask-4 / ST2 rework — see
316    /// `submit_output`'s doc). Synchronous (a plain `std::sync::RwLock`
317    /// write) so a caller can wire it up at boot from a non-`async`
318    /// context (`mlua-swarm-server`'s router builder passes the same
319    /// `Arc` it hands to its `AppState.data_store`, so `POST
320    /// /v1/data/emit` and every worker's ordinary `/v1/worker/submit` land
321    /// in the one store). Calling this more than once replaces the
322    /// previous backend; not calling it at all (the default) preserves
323    /// pre-subtask-4 behavior exactly — `submit_output` only touches the
324    /// Domain-plane `EngineState.output_store` HashMap.
325    pub fn set_output_store(&self, store: Arc<dyn crate::store::output::OutputStore>) {
326        let mut guard = self
327            .inner
328            .data_store
329            .write()
330            .unwrap_or_else(|poisoned| poisoned.into_inner());
331        *guard = Some(store);
332    }
333
334    /// Clones the currently-wired Data-plane store handle, if any. Kept
335    /// private and side-effect-free (no lock held past this call) —
336    /// callers (`materialize_final_submission`) do their actual `.append`
337    /// work outside of any lock.
338    fn output_store_backend(&self) -> Option<Arc<dyn crate::store::output::OutputStore>> {
339        self.inner
340            .data_store
341            .read()
342            .unwrap_or_else(|poisoned| poisoned.into_inner())
343            .clone()
344    }
345
346    /// GH #50 (Subtask 2): merges `contracts` (agent name → declared
347    /// [`mlua_swarm_schema::VerdictContract`]) into the engine's runtime
348    /// verdict-contract registry, later resolved per-task by
349    /// [`Self::verdict_contract_for_task`]. Same sync-write idiom as
350    /// [`Self::set_output_store`] — a plain `std::sync::RwLock` write, so
351    /// this can be called from a non-`async` context. Production call
352    /// site: `TaskLaunchService::launch`, immediately after a successful
353    /// `Compiler::compile`, passing `compiled.router.verdict_contracts.clone()`.
354    ///
355    /// # Overwrite semantics (explicit — read before adding a second call site)
356    ///
357    /// The registry is a single flat `HashMap` **keyed by agent name only**
358    /// (`String`), with process-wide (not per-task, not per-Blueprint,
359    /// not per-launch) scope. Registration is additive via
360    /// `HashMap::extend`: an entry for an agent name NOT already present is
361    /// added; an entry for an agent name ALREADY present is REPLACED
362    /// (last write wins) by the incoming one. Concretely: launching a
363    /// second Blueprint that also declares a `verdict` contract for an
364    /// agent named `"gate"` OVERWRITES whatever contract a first, still
365    /// in-flight, launch registered for an agent of that same name — even
366    /// if the two Blueprints intend it as two semantically different
367    /// agents that merely share a name, and even while the first launch's
368    /// tasks are still running. This is a **known limitation** of the v1
369    /// design; a per-task (or per-`RunId` / per-Blueprint) scoped registry
370    /// is a possible follow-up if two concurrently in-flight Blueprints
371    /// declaring conflicting contracts under the same agent name turns out
372    /// to matter in practice. Calling this with an empty map (or not at
373    /// all — the default) is a no-op, preserving pre-GH-#50 behavior
374    /// exactly (opt-in).
375    pub fn register_verdict_contracts(
376        &self,
377        contracts: HashMap<String, mlua_swarm_schema::VerdictContract>,
378    ) {
379        let mut guard = self
380            .inner
381            .verdict_contracts
382            .write()
383            .unwrap_or_else(|poisoned| poisoned.into_inner());
384        guard.extend(contracts);
385    }
386
387    /// GH #50 (Subtask 2): the declared
388    /// [`mlua_swarm_schema::VerdictContract`] for the agent currently
389    /// running `task_id`, if any. Resolves `task_id` → `TaskState.spec.agent`
390    /// (via `EngineState.tasks`, the same lookup [`Self::task_attempt`]
391    /// performs) and looks that agent name up in the registry
392    /// [`Self::register_verdict_contracts`] populates.
393    ///
394    /// `None` in both of these cases — deliberately collapsed to the same
395    /// value, mirroring [`Self::agent_context_for`]'s `Result`-into-`Option`
396    /// pattern (`.ok().flatten()`; a lookup failure here is never itself an
397    /// error worth surfacing to a caller):
398    /// - `task_id` is unknown (no `TaskState` for it).
399    /// - `task_id` resolves to a known agent, but that agent declared no
400    ///   `verdict` contract (the opt-in default).
401    ///
402    /// Callers (`mlua-swarm-server`'s `worker_submit` / `worker_artifact`)
403    /// treat every `None` identically: skip the submit-time verdict gate
404    /// entirely, preserving pre-GH-#50 behavior byte-for-byte.
405    pub async fn verdict_contract_for_task(
406        &self,
407        task_id: &StepId,
408    ) -> Option<mlua_swarm_schema::VerdictContract> {
409        let tid = task_id.clone();
410        let agent = self
411            .with_state("verdict_contract_for_task", move |s| {
412                s.tasks.get(&tid).map(|t| t.spec.agent.clone())
413            })
414            .await
415            .ok()
416            .flatten()?;
417        self.inner
418            .verdict_contracts
419            .read()
420            .unwrap_or_else(|poisoned| poisoned.into_inner())
421            .get(&agent)
422            .cloned()
423    }
424
425    /// GH #51 — the value of the LAST staged `"verdict"` `Artifact` for
426    /// `(task_id, attempt)`, if any. Mirrors [`fold_final_and_parts`]'s
427    /// reverse-scan-of-`output_tail` pattern (last-write-wins per name,
428    /// same as that fold and [`Self::stage_worker_artifact_trusted`]'s
429    /// doc), narrowed to the single literal artifact name
430    /// `channel: "part"` contracts address (Pattern B — see
431    /// `blueprint-authoring.md`'s "Returning verdicts to drive BP flow").
432    ///
433    /// Infallible accessor: `None` is the normal "nothing staged yet"
434    /// case, not an error — the caller
435    /// ([`Self::verdict_contract_completion_check`]) is what converts
436    /// `None` into `Err(EngineError::VerdictPartMissing)`.
437    pub(crate) async fn staged_verdict_value_for(
438        &self,
439        task_id: &StepId,
440        attempt: u32,
441    ) -> Option<String> {
442        let tail = self.output_tail(task_id, attempt).await;
443        tail.iter().rev().find_map(|ev| match ev {
444            crate::worker::output::OutputEvent::Artifact { name, content } if name == "verdict" => {
445                Some(content_ref_to_comparable_string(content.clone()))
446            }
447            _ => None,
448        })
449    }
450
451    /// GH #51 — the single completion-time verdict-contract choke point,
452    /// embedded inside BOTH [`Self::submit_worker_result_trusted`] and
453    /// [`Self::submit_output`] (the two engine-side writes every HTTP/WS
454    /// completion route ultimately passes through). Not duplicated per
455    /// route handler — a future 4th completion route is gated for free
456    /// as long as it funnels through one of those two functions.
457    ///
458    /// `ok=false` is exempt on every route (this single early-return IS
459    /// the exemption, reused identically by both embedding sites — see
460    /// issue #51's "ok=false completions are exempt" acceptance
461    /// criterion). An agent with no declared contract, or a contract for
462    /// the OTHER channel, is untouched (`Ok(())`) — same opt-in,
463    /// byte-for-byte-preserving posture as
464    /// [`Self::verdict_contract_for_task`]'s doc.
465    ///
466    /// - `channel: "body"` — `value` (the completing `Final`'s content,
467    ///   already reduced to a comparable string by the caller via
468    ///   [`content_ref_to_comparable_string`]) must be a member of
469    ///   `contract.values`.
470    /// - `channel: "part"` — [`Self::staged_verdict_value_for`] must find
471    ///   a staged `"verdict"` artifact for this attempt (presence,
472    ///   defense in depth over the staging-time membership check) AND its
473    ///   value must be a member of `contract.values`.
474    async fn verdict_contract_completion_check(
475        &self,
476        task_id: &StepId,
477        attempt: u32,
478        ok: bool,
479        value: &str,
480    ) -> Result<(), EngineError> {
481        if !ok {
482            return Ok(());
483        }
484        let Some(contract) = self.verdict_contract_for_task(task_id).await else {
485            return Ok(());
486        };
487        match contract.channel {
488            mlua_swarm_schema::VerdictChannel::Body => {
489                if contract.values.iter().any(|v| v == value) {
490                    Ok(())
491                } else {
492                    Err(EngineError::VerdictValueRejected {
493                        value: value.to_string(),
494                        allowed: contract.values.clone(),
495                    })
496                }
497            }
498            mlua_swarm_schema::VerdictChannel::Part => {
499                match self.staged_verdict_value_for(task_id, attempt).await {
500                    None => Err(EngineError::VerdictPartMissing {
501                        allowed: contract.values.clone(),
502                    }),
503                    Some(staged) if contract.values.iter().any(|v| v == &staged) => Ok(()),
504                    Some(staged) => Err(EngineError::VerdictValueRejected {
505                        value: staged,
506                        allowed: contract.values.clone(),
507                    }),
508                }
509            }
510        }
511    }
512
513    // ═══════════════════════════════════════════════════════════════════════
514    // §7 with_state — single Mutex + R1-R4 (try_lock + bounded retry + max-hold panic)
515    // ═══════════════════════════════════════════════════════════════════════
516
517    /// The closure is a **sync** `FnOnce` — you cannot pass an async
518    /// closure, which enforces R3 at the type level. Exceeding `max_hold`
519    /// panics so that R4 violations surface immediately.
520    pub async fn with_state<F, R>(&self, op: &'static str, f: F) -> Result<R, EngineError>
521    where
522        F: FnOnce(&mut EngineState) -> R,
523    {
524        let cfg = &self.inner.cfg;
525
526        // R2: try_lock + bounded retry
527        let mut guard_opt = None;
528        for attempt in 0..=cfg.max_retry {
529            match self.inner.state.try_lock() {
530                Ok(g) => {
531                    guard_opt = Some(g);
532                    break;
533                }
534                Err(_) if cfg.try_only => return Err(EngineError::LockBusy(op)),
535                Err(_) => {
536                    let backoff = cfg.backoff_ms_step * (attempt as u64 + 1);
537                    tokio::time::sleep(Duration::from_millis(backoff)).await;
538                }
539            }
540        }
541        let mut guard = guard_opt.ok_or(EngineError::LockBusyAfterRetry(op))?;
542
543        // R4: max_hold guard
544        let start = Instant::now();
545        let result = f(&mut guard);
546        let elapsed_ms = start.elapsed().as_millis();
547        drop(guard);
548
549        if elapsed_ms > cfg.max_hold_ms {
550            panic!(
551                "Engine.with_state('{op}') held {elapsed_ms}ms > max {}ms — suspected R3 violation (long op inside lock)",
552                cfg.max_hold_ms
553            );
554        }
555        Ok(result)
556    }
557
558    // ═══════════════════════════════════════════════════════════════════════
559    // Token verify (= sig + expire + gate + uses_left)
560    // ═══════════════════════════════════════════════════════════════════════
561
562    /// Four steps: (1) signature verify, (2) expiry check, (3) role × verb
563    /// gate, (4) `uses_left` consume.
564    pub async fn verify_token(&self, token: &CapToken, verb: Verb) -> Result<(), EngineError> {
565        // (1) sig
566        if !self.inner.signer.verify_sig(token) {
567            return Err(EngineError::BadSignature);
568        }
569        // (2) expire
570        if token.is_expired(now_unix()) {
571            return Err(EngineError::TokenExpired);
572        }
573        // (3) role × verb gate
574        if !self.inner.gate.is_allowed(token.role, verb) {
575            return Err(EngineError::RoleViolation {
576                role: token.role,
577                verb,
578            });
579        }
580        // (4) server-side uses_left consume
581        let fp = token.fingerprint();
582        self.with_state("token.consume", move |s| {
583            let rec = s
584                .tokens
585                .get_mut(&fp)
586                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))?;
587            rec.consume()
588                .map_err(|_: crate::core::state::CapTokenConsumeError| {
589                    EngineError::TokenUsesExhausted
590                })?;
591            Ok::<(), EngineError>(())
592        })
593        .await??;
594        Ok(())
595    }
596
597    /// `verify_token` plus the **task-ownership gate**.
598    ///
599    /// When a Worker-role token calls a state-touch verb (`fetch_prompt` /
600    /// `post_result` / `read_task_state` / `cancel_task` / `poll_task`),
601    /// the gate checks that `CapTokenRecord.task_id` matches the argument
602    /// `task_id`; a mismatch returns `EngineError::TokenTaskMismatch`.
603    /// Operator / Senior / Observer tokens are outside the ownership gate
604    /// and may touch any task.
605    ///
606    /// **Verbs exempt from the gate.** `start_task` and `dispatch_attempt`
607    /// stay outside so recursive swarming keeps working; depth is capped
608    /// by `max_spawn_depth`.
609    pub async fn verify_token_for_task(
610        &self,
611        token: &CapToken,
612        verb: Verb,
613        task_id: &StepId,
614    ) -> Result<(), EngineError> {
615        self.verify_token(token, verb).await?;
616        if token.role != Role::Worker {
617            return Ok(());
618        }
619        let fp = token.fingerprint();
620        let arg_tid = task_id.clone();
621        self.with_state("token.ownership_gate", move |s| {
622            let bound = s.tokens.get(&fp).and_then(|r| r.task_id.as_ref()).cloned();
623            match bound {
624                Some(t) if t == arg_tid => Ok(()),
625                Some(t) => Err(EngineError::TokenTaskMismatch {
626                    bound: t.into_string(),
627                    arg: arg_tid.into_string(),
628                }),
629                None => Err(EngineError::TokenNotFound(fp.clone())),
630            }
631        })
632        .await??;
633        Ok(())
634    }
635
636    /// Resolve the bound `task_id` from a Worker-role token. Used on the
637    /// simple `/v1/worker/submit` endpoint, where the worker POSTs with a
638    /// token but no `task_id`. Returns `Err` if the token role is not
639    /// Worker, or if no bound task is set.
640    pub async fn task_id_from_token(&self, token: &CapToken) -> Result<StepId, EngineError> {
641        if token.role != Role::Worker {
642            return Err(EngineError::RoleViolation {
643                role: token.role,
644                verb: Verb::PostResult,
645            });
646        }
647        let fp = token.fingerprint();
648        self.with_state("task_id_from_token", move |s| {
649            s.tokens
650                .get(&fp)
651                .and_then(|r| r.task_id.as_ref())
652                .cloned()
653                .ok_or_else(|| EngineError::TokenNotFound(fp.clone()))
654        })
655        .await?
656    }
657
658    /// Resolve a short worker handle (`wh-XXXXXXXX`) to the bound
659    /// `task_id`. Used on `/v1/worker/submit` when the Bearer is a short
660    /// handle string rather than a full `CapToken` JSON. A missing entry
661    /// returns `TokenNotFound`, i.e. "the handle is not in the store".
662    pub async fn task_id_from_handle(&self, handle: &str) -> Result<StepId, EngineError> {
663        let h = handle.to_string();
664        self.with_state("task_id_from_handle", move |s| {
665            let fp = s
666                .worker_handles
667                .get(&h)
668                .cloned()
669                .ok_or_else(|| EngineError::TokenNotFound(format!("handle={h}")))?;
670            s.tokens
671                .get(&fp)
672                .and_then(|r| r.task_id.as_ref())
673                .cloned()
674                .ok_or_else(|| EngineError::TokenNotFound(format!("fp={fp}")))
675        })
676        .await?
677    }
678
679    /// Submit a worker result via a short handle. Skips token verification
680    /// and updates `output_tail` `Final` + `task.last_result` directly in
681    /// a thin path. The caller is expected to have already resolved
682    /// `task_id` via `task_id_from_handle` — the handle's presence in
683    /// `worker_handles` means it was minted server-side and is therefore
684    /// trusted.
685    ///
686    /// # GH #76 Skip tier: `outcome: SubmitOutcome`
687    ///
688    /// The `outcome` parameter (replacing the pre-#76 `ok: bool`) is the
689    /// caller's tier declaration:
690    ///
691    /// | outcome  | `Final.ok` | `Final.content`                | verdict-contract check |
692    /// |----------|------------|--------------------------------|------------------------|
693    /// | `Pass`   | `true`     | `value` verbatim               | fires                  |
694    /// | `Blocked`| `false`    | `value` verbatim               | exempt (`ok=false`)    |
695    /// | `Skip`   | `true`     | `wrap_skip_marker(value)`      | exempt (Skip opt-out)  |
696    ///
697    /// The Skip tier is opt-out from the verdict-contract completion check
698    /// on the same rationale [`crate::core::state::SubmitOutcome::Skip`]'s
699    /// doc records: the agent explicitly declared "not applicable", so
700    /// the payload is not a real verdict value to gate.
701    pub async fn submit_worker_result_trusted(
702        &self,
703        task_id: &StepId,
704        attempt: u32,
705        value: Value,
706        outcome: SubmitOutcome,
707    ) -> Result<(), EngineError> {
708        // Resolve outcome into the wire-level (value, ok, run_contract)
709        // triple exactly once, then reuse it below. Keeping the mapping
710        // literal in one place makes the "Skip wraps + skips contract"
711        // invariant grep-visible.
712        let (wire_value, wire_ok, run_contract_check) = match outcome {
713            SubmitOutcome::Pass => (value, true, true),
714            SubmitOutcome::Blocked => (value, false, false),
715            SubmitOutcome::Skip => (wrap_skip_marker(value), true, false),
716        };
717
718        // GH #51 — completion-time verdict-contract enforcement, embedded
719        // choke point 1 of 2 (see `Self::verdict_contract_completion_check`'s
720        // doc). This path always submits a `Final` by construction (there
721        // is no other event kind on `/v1/worker/submit`), so the check
722        // always applies — unlike `submit_output` below, no `if let
723        // OutputEvent::Final { .. }` guard is needed here since there is
724        // no other `OutputEvent` variant this function could be asked to
725        // write. Runs BEFORE the `output_tail` write immediately below:
726        // on `Err`, this returns immediately and neither `with_state` call
727        // in this function executes.
728        //
729        // GH #76 Skip tier: gated on `run_contract_check` — Skip is opt-out
730        // (see the outcome mapping table above), Blocked stays exempt via
731        // `verdict_contract_completion_check`'s existing `ok=false`
732        // early return (redundant flag here for grep locality).
733        if run_contract_check {
734            let comparable_value =
735                content_ref_to_comparable_string(crate::worker::output::ContentRef::Inline {
736                    value: wire_value.clone(),
737                });
738            self.verdict_contract_completion_check(task_id, attempt, wire_ok, &comparable_value)
739                .await?;
740        }
741        let task_id_for_apply = task_id.clone();
742        let value_for_event = wire_value.clone();
743        self.with_state("submit_worker_result_trusted.output", move |s| {
744            let ev = crate::worker::output::OutputEvent::Final {
745                content: crate::worker::output::ContentRef::Inline {
746                    value: value_for_event,
747                },
748                ok: wire_ok,
749            };
750            s.output_store
751                .entry((task_id_for_apply.clone(), attempt))
752                .or_default()
753                .push(ev.clone());
754            s.push_event(crate::core::state::Event::WorkerOutput {
755                task_id: task_id_for_apply,
756                attempt,
757                event: ev,
758            });
759        })
760        .await?;
761        let task_id_for_result = task_id.clone();
762        let value_for_result = wire_value.clone();
763        self.with_state("submit_worker_result_trusted.last_result", move |s| {
764            if let Some(t) = s.tasks.get_mut(&task_id_for_result) {
765                t.last_result = Some(value_for_result);
766                t.updated_at = now_unix();
767            }
768        })
769        .await?;
770        // subtask-4 / ST2 rework: this path always submits a `Final` (there
771        // is no other event kind on `/v1/worker/submit`), so the
772        // submit-time projection sink always fires — see
773        // `materialize_final_submission`'s doc and `submit_output`'s
774        // Invariants (fail-open, never turns a would-have-succeeded submit
775        // into a failure).
776        let content = crate::worker::output::ContentRef::Inline { value: wire_value };
777        self.materialize_final_submission(task_id, attempt, &content, wire_ok)
778            .await?;
779        Ok(())
780    }
781
782    /// Stage a named `Artifact` from a worker via a short handle (GH #36
783    /// ST1: named multi-part worker output). Trusted analog of
784    /// [`Self::submit_worker_result_trusted`] for `OutputEvent::Artifact`:
785    /// skips token verification for the same reason (the caller already
786    /// resolved `task_id` via `task_id_from_handle`, so the handle's
787    /// presence in `worker_handles` is itself the trust boundary).
788    ///
789    /// Appends to the same per-`(task_id, attempt)` `output_store` tail
790    /// [`Self::dispatch_attempt_with`]'s Final-pull later folds into
791    /// `{"out": <final>, "parts": {<name>: <value>, ...}}` (see that
792    /// method's doc for the fold semantics — event order, last-write-wins
793    /// per name), AND records `name` in `EngineState.worker_artifact_names`
794    /// — the fold's allowlist of the WORKER's own staged parts, as opposed
795    /// to every `Artifact` that happens to land on the shared tail (e.g. an
796    /// audit sidecar finding; see that field's doc). Also dual-writes to
797    /// the Data-plane `OutputStore` the same way [`Self::submit_output`]'s
798    /// `Artifact` arm does, via [`Self::materialize_artifact_submission`]
799    /// (the artifact's own `name` is its Data-plane key, no
800    /// canonicalization — see that method's doc).
801    pub async fn stage_worker_artifact_trusted(
802        &self,
803        task_id: &StepId,
804        attempt: u32,
805        name: String,
806        value: Value,
807    ) -> Result<(), EngineError> {
808        let content = crate::worker::output::ContentRef::Inline { value };
809        let task_id_for_apply = task_id.clone();
810        let name_for_apply = name.clone();
811        let content_for_apply = content.clone();
812        self.with_state("stage_worker_artifact_trusted.output", move |s| {
813            let ev = crate::worker::output::OutputEvent::Artifact {
814                name: name_for_apply.clone(),
815                content: content_for_apply,
816            };
817            s.output_store
818                .entry((task_id_for_apply.clone(), attempt))
819                .or_default()
820                .push(ev.clone());
821            s.worker_artifact_names
822                .entry((task_id_for_apply.clone(), attempt))
823                .or_default()
824                .push(name_for_apply);
825            s.push_event(crate::core::state::Event::WorkerOutput {
826                task_id: task_id_for_apply,
827                attempt,
828                event: ev,
829            });
830        })
831        .await?;
832        self.materialize_artifact_submission(task_id, attempt, &name, &content)
833            .await?;
834        Ok(())
835    }
836
837    /// GH #36 ST1: the set of `Artifact` names staged for `(task_id,
838    /// attempt)` via [`Self::stage_worker_artifact_trusted`] — see
839    /// `EngineState.worker_artifact_names`'s doc. Used by
840    /// [`Self::dispatch_attempt_with`]'s Final-pull to distinguish a
841    /// worker's own named parts from any other `Artifact` producer on the
842    /// same tail.
843    async fn worker_artifact_names_for(&self, task_id: &StepId, attempt: u32) -> Vec<String> {
844        let key = (task_id.clone(), attempt);
845        self.with_state("worker_artifact_names_for", move |s| {
846            s.worker_artifact_names
847                .get(&key)
848                .cloned()
849                .unwrap_or_default()
850        })
851        .await
852        .unwrap_or_default()
853    }
854
855    /// Mint a short handle and register it in the `worker_handles` map.
856    /// Called immediately after the worker-token mint inside
857    /// `dispatch_attempt_with`, and issues a handle bound to the same
858    /// token fingerprint. Format is `wh-<8 hex chars>` (11 chars total),
859    /// designed to remove the base64 copy-paste failure mode.
860    async fn mint_worker_handle(&self, worker_fp: String) -> Result<String, EngineError> {
861        // The handle is a sole bearer secret on the `/v1/worker/submit`
862        // short-handle path (`submit_worker_result_trusted` skips token
863        // verification), so it must be unguessable — OS RNG, not the
864        // predictable uid counter. 8 hex chars (~4B entropy) keeps the
865        // documented `wh-<8 hex>` wire shape; collision between live
866        // handles is negligible at in-process handle counts.
867        let short = crate::types::secure_hex(4);
868        let handle = format!("wh-{short}");
869        let h = handle.clone();
870        self.with_state("mint_worker_handle", move |s| {
871            s.worker_handles.insert(h, worker_fp);
872        })
873        .await?;
874        Ok(handle)
875    }
876
877    // ═══════════════════════════════════════════════════════════════════════
878    // Session API
879    // ═══════════════════════════════════════════════════════════════════════
880
881    /// Attach a new session with default `OperatorInfo` (`Automate`, no
882    /// bridges/hooks). Shorthand for `attach_with(.., OperatorInfo::default())`.
883    pub async fn attach(
884        &self,
885        operator_id: impl Into<String>,
886        role: Role,
887        ttl: Duration,
888    ) -> Result<CapToken, EngineError> {
889        self.attach_with(
890            operator_id,
891            role,
892            ttl,
893            crate::core::ctx::OperatorInfo::default(),
894        )
895        .await
896    }
897
898    // ═══════════════════════════════════════════════════════════════════════
899    // BridgeRegistry API.
900    // ═══════════════════════════════════════════════════════════════════════
901
902    /// Register a `SeniorBridge` under a name. An existing entry with the
903    /// same name is overwritten. On the persisted-session reattach path,
904    /// the caller re-registers under the same ID beforehand and the
905    /// bridge becomes effective again.
906    pub async fn register_senior_bridge(
907        &self,
908        id: impl Into<String>,
909        bridge: Arc<dyn SeniorBridge>,
910    ) {
911        self.inner
912            .senior_bridges
913            .write()
914            .await
915            .insert(id.into(), bridge);
916    }
917
918    /// Register a `SpawnHook` under a name. An existing entry with the
919    /// same name is overwritten.
920    pub async fn register_spawn_hook(&self, id: impl Into<String>, hook: Arc<dyn SpawnHook>) {
921        self.inner.spawn_hooks.write().await.insert(id.into(), hook);
922    }
923
924    /// Register an `Operator` (a spawn-body backend) under a name. An
925    /// existing entry with the same name is overwritten.
926    /// `OperatorDelegateMiddleware` looks this up via `ctx` and, when
927    /// `kind = MainAi` / `Composite`, bypasses `inner.spawn` and calls
928    /// `operator.execute` instead.
929    pub async fn register_operator(
930        &self,
931        id: impl Into<String>,
932        operator: Arc<dyn crate::operator::Operator>,
933    ) {
934        self.inner
935            .operators
936            .write()
937            .await
938            .insert(id.into(), operator);
939    }
940
941    /// Unregister a `SeniorBridge` by name (e.g. on WebSocket disconnect
942    /// or explicit teardown). A missing ID is a no-op.
943    pub async fn unregister_senior_bridge(&self, id: &str) {
944        self.inner.senior_bridges.write().await.remove(id);
945    }
946
947    /// Unregister a `SpawnHook` by name. A missing ID is a no-op.
948    pub async fn unregister_spawn_hook(&self, id: &str) {
949        self.inner.spawn_hooks.write().await.remove(id);
950    }
951
952    /// Unregister an `Operator` backend by name. A missing ID is a no-op.
953    pub async fn unregister_operator(&self, id: &str) {
954        self.inner.operators.write().await.remove(id);
955    }
956
957    /// Snapshot the list of registered `SpawnHook` IDs (for test
958    /// observation and debugging).
959    pub async fn list_spawn_hook_ids(&self) -> Vec<String> {
960        self.inner
961            .spawn_hooks
962            .read()
963            .await
964            .keys()
965            .cloned()
966            .collect()
967    }
968
969    /// Snapshot the list of registered `SeniorBridge` IDs.
970    pub async fn list_senior_bridge_ids(&self) -> Vec<String> {
971        self.inner
972            .senior_bridges
973            .read()
974            .await
975            .keys()
976            .cloned()
977            .collect()
978    }
979
980    /// Snapshot the list of registered `Operator` IDs.
981    pub async fn list_operator_ids(&self) -> Vec<String> {
982        self.inner.operators.read().await.keys().cloned().collect()
983    }
984
985    /// Attach specifying IDs directly. The caller is expected to have
986    /// pre-registered them via `register_senior_bridge` /
987    /// `register_spawn_hook` / `register_operator`. This is the canonical
988    /// path when persistence is in play.
989    ///
990    /// `kind` is the "Runtime Global" tier of the `OperatorKind` cascade
991    /// (stored verbatim on `OperatorSession.operator_kind`): `Some(_)` is
992    /// an explicit request (including `Some(OperatorKind::Automate)`) that
993    /// outranks the BP-level tiers; `None` leaves it unspecified so the
994    /// BP-level tiers / final default decide. See
995    /// `crate::core::ctx::collapse_operator_kind`.
996    #[allow(clippy::too_many_arguments)]
997    pub async fn attach_with_ids(
998        &self,
999        operator_id: impl Into<String>,
1000        role: Role,
1001        ttl: Duration,
1002        kind: Option<OperatorKind>,
1003        bridge_id: Option<String>,
1004        hook_id: Option<String>,
1005        operator_backend_id: Option<String>,
1006        operator_kind_overrides: HashMap<String, OperatorKind>,
1007        bp_agent_kinds: HashMap<String, OperatorKind>,
1008        bp_global_kind: Option<OperatorKind>,
1009    ) -> Result<CapToken, EngineError> {
1010        let operator_id = operator_id.into();
1011        let token = self
1012            .inner
1013            .signer
1014            .session(operator_id.clone(), role, vec!["*".into()], ttl);
1015        let session_id = SessionId::new();
1016        let fp = token.fingerprint();
1017        let now = now_unix();
1018        let token_for_store = token.clone();
1019
1020        self.with_state("attach_with_ids", |s| {
1021            s.tokens
1022                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
1023            s.sessions.insert(
1024                session_id.clone(),
1025                OperatorSession {
1026                    id: session_id.clone(),
1027                    operator_id: operator_id.clone(),
1028                    role,
1029                    attached_at: now,
1030                    last_seen: now,
1031                    attached: true,
1032                    owned_task_ids: Vec::new(),
1033                    token_fp: fp.clone(),
1034                    operator_kind: kind,
1035                    runtime_agent_kinds: operator_kind_overrides,
1036                    bp_agent_kinds,
1037                    bp_global_kind,
1038                    bridge_id,
1039                    hook_id,
1040                    operator_backend_id,
1041                },
1042            );
1043            s.push_event(Event::SessionAttached {
1044                session_id: session_id.clone(),
1045                role,
1046            });
1047        })
1048        .await?;
1049
1050        let _ = self
1051            .inner
1052            .event_tx
1053            .send(Event::SessionAttached { session_id, role });
1054        Ok(token)
1055    }
1056
1057    /// Build an `OperatorInfo` by looking up the session's registered IDs
1058    /// on the `BridgeRegistry`, plus resolving the 4-tier `OperatorKind`
1059    /// cascade for `agent_name` via `crate::core::ctx::collapse_operator_kind`.
1060    /// Used when `dispatch_attempt` injects `Ctx`. An unresolved ID
1061    /// (nothing registered) is silently `None` — the bridge / hook simply
1062    /// does not fire and the default behaviour applies.
1063    async fn resolve_operator_info(
1064        &self,
1065        session: &OperatorSession,
1066        agent_name: &str,
1067    ) -> OperatorInfo {
1068        let senior_bridge = if let Some(id) = &session.bridge_id {
1069            self.inner.senior_bridges.read().await.get(id).cloned()
1070        } else {
1071            None
1072        };
1073        let spawn_hook = if let Some(id) = &session.hook_id {
1074            self.inner.spawn_hooks.read().await.get(id).cloned()
1075        } else {
1076            None
1077        };
1078        let operator = if let Some(id) = &session.operator_backend_id {
1079            self.inner.operators.read().await.get(id).cloned()
1080        } else {
1081            None
1082        };
1083        let runtime_agent = session.runtime_agent_kinds.get(agent_name).copied();
1084        // "Runtime Global" tier: `Some(_)` is always an explicit request
1085        // (see the field doc on `OperatorSession.operator_kind`).
1086        let runtime_global = session.operator_kind;
1087        let bp_agent = session.bp_agent_kinds.get(agent_name).copied();
1088        let bp_global = session.bp_global_kind;
1089        let kind = crate::core::ctx::collapse_operator_kind(
1090            runtime_agent,
1091            runtime_global,
1092            bp_agent,
1093            bp_global,
1094        );
1095        OperatorInfo {
1096            kind,
1097            id: session.operator_id.clone(),
1098            senior_bridge,
1099            spawn_hook,
1100            operator,
1101        }
1102    }
1103
1104    /// Convenience attach that takes an `OperatorInfo` (three
1105    /// `Arc<dyn ...>` fields plus `kind`) **inline**.
1106    ///
1107    /// # Pipeline
1108    ///
1109    /// Each `Arc<dyn ...>` is auto-registered on the engine's registry
1110    /// under a synthetic ID (`br-<hex>` / `hk-<hex>` / `ob-<hex>`), and
1111    /// the session stores that synthetic ID. Subsequent `dispatch_attempt`
1112    /// calls rebuild the `Arc`s from those IDs via
1113    /// `resolve_operator_info`, and the three middlewares fire as usual.
1114    ///
1115    /// # ⚠ Non-persisted sessions only
1116    ///
1117    /// Because this API takes inline `Arc`s, the reattach path after
1118    /// session persistence cannot rebuild them — the synthetic IDs are
1119    /// not present in a freshly started process's registry. If you need
1120    /// persistence, use [`Self::attach_with_ids`] with `register_*` calls
1121    /// beforehand to go through **named IDs** instead.
1122    ///
1123    /// Handy for tests and short-lived in-process sessions. Production
1124    /// WebSocket callbacks and the like should prefer `attach_with_ids`
1125    /// as the canonical path.
1126    pub async fn attach_with(
1127        &self,
1128        operator_id: impl Into<String>,
1129        role: Role,
1130        ttl: Duration,
1131        operator_info: crate::core::ctx::OperatorInfo,
1132    ) -> Result<CapToken, EngineError> {
1133        let operator_id = operator_id.into();
1134        // The caller always hands in a fully-formed `OperatorInfo`
1135        // (including its `kind`), so it is stored as an explicit "Runtime
1136        // Global" tier request (`Some(kind)`) — this path never persists
1137        // BP-level tiers (both stay empty below), so `Some(kind)` resolves
1138        // to the same `kind` at dispatch either way; see
1139        // `OperatorSession.operator_kind` doc.
1140        let kind = operator_info.kind;
1141        // BridgeRegistry auto-register: when the caller hands in an
1142        // `Arc<dyn>` directly, register it under a synthesised ID (the inline
1143        // path aware of persistence). Callers who want to pre-register with a
1144        // named ID should use `register_senior_bridge` / `register_spawn_hook`
1145        // + `attach_with_ids`.
1146        let bridge_id = if let Some(bridge) = operator_info.senior_bridge.clone() {
1147            let id = format!("br-{}", crate::types::uid_hex(8));
1148            self.inner
1149                .senior_bridges
1150                .write()
1151                .await
1152                .insert(id.clone(), bridge);
1153            Some(id)
1154        } else {
1155            None
1156        };
1157        let hook_id = if let Some(hook) = operator_info.spawn_hook.clone() {
1158            let id = format!("hk-{}", crate::types::uid_hex(8));
1159            self.inner
1160                .spawn_hooks
1161                .write()
1162                .await
1163                .insert(id.clone(), hook);
1164            Some(id)
1165        } else {
1166            None
1167        };
1168        let operator_backend_id = if let Some(operator) = operator_info.operator.clone() {
1169            // `ob-` = operator-backend registry id. Renamed from `op-` in the
1170            // issue #11 prefix reconciliation: `op-` used to collide with the
1171            // WS operator sid shape (now unified into `S-<hex>` anyway), and a
1172            // shared prefix across two unrelated registries made log filtering
1173            // by prefix silently ambiguous.
1174            let id = format!("ob-{}", crate::types::uid_hex(8));
1175            self.inner
1176                .operators
1177                .write()
1178                .await
1179                .insert(id.clone(), operator);
1180            Some(id)
1181        } else {
1182            None
1183        };
1184
1185        let token = self
1186            .inner
1187            .signer
1188            .session(operator_id.clone(), role, vec!["*".into()], ttl);
1189        let session_id = SessionId::new();
1190        let fp = token.fingerprint();
1191        let now = now_unix();
1192        let token_for_store = token.clone();
1193
1194        self.with_state("attach_with", |s| {
1195            s.tokens
1196                .insert(fp.clone(), CapTokenRecord::from_token(token_for_store));
1197            s.sessions.insert(
1198                session_id.clone(),
1199                OperatorSession {
1200                    id: session_id.clone(),
1201                    operator_id,
1202                    role,
1203                    attached_at: now,
1204                    last_seen: now,
1205                    attached: true,
1206                    owned_task_ids: Vec::new(),
1207                    token_fp: fp.clone(),
1208                    operator_kind: Some(kind),
1209                    runtime_agent_kinds: HashMap::new(),
1210                    bp_agent_kinds: HashMap::new(),
1211                    bp_global_kind: None,
1212                    bridge_id,
1213                    hook_id,
1214                    operator_backend_id,
1215                },
1216            );
1217            s.push_event(Event::SessionAttached {
1218                session_id: session_id.clone(),
1219                role,
1220            });
1221        })
1222        .await?;
1223
1224        let _ = self
1225            .inner
1226            .event_tx
1227            .send(Event::SessionAttached { session_id, role });
1228        Ok(token)
1229    }
1230
1231    /// Mark the session bound to `token` as detached (`attached = false`).
1232    /// Tasks are left in place — a later `attach`/`attach_with_ids` call
1233    /// carrying the same registered bridge/hook IDs can pick them back up.
1234    pub async fn detach(&self, token: &CapToken) -> Result<(), EngineError> {
1235        self.verify_token(token, Verb::DetachSession).await?;
1236        let fp = token.fingerprint();
1237        self.with_state("detach", move |s| {
1238            let sid = s
1239                .sessions
1240                .iter()
1241                .find(|(_, sess)| sess.token_fp == fp)
1242                .map(|(id, _)| id.clone());
1243            if let Some(sid) = sid {
1244                if let Some(sess) = s.sessions.get_mut(&sid) {
1245                    sess.attached = false;
1246                }
1247                s.push_event(Event::SessionDetached {
1248                    session_id: sid.clone(),
1249                });
1250                let _ = sid;
1251            }
1252        })
1253        .await?;
1254        Ok(())
1255    }
1256
1257    /// Refresh the session's `last_seen` timestamp and mark it `attached`.
1258    /// Called periodically by an attached client to avoid being flipped to
1259    /// detached by `start_detach_loop`.
1260    pub async fn heartbeat(&self, token: &CapToken) -> Result<(), EngineError> {
1261        self.verify_token(token, Verb::Heartbeat).await?;
1262        let now = now_unix();
1263        let fp = token.fingerprint();
1264        self.with_state("heartbeat", move |s| {
1265            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
1266                sess.last_seen = now;
1267                sess.attached = true;
1268            }
1269        })
1270        .await?;
1271        Ok(())
1272    }
1273
1274    // ═══════════════════════════════════════════════════════════════════════
1275    // Task lifecycle
1276    // ═══════════════════════════════════════════════════════════════════════
1277
1278    /// Create a new `TaskState` from `spec` and register its initial
1279    /// prompt. When the calling token is a Worker (i.e. this is a
1280    /// recursive spawn), the new task inherits `parent.spawn_depth + 1`
1281    /// and is rejected with `SpawnDepthExceeded` once `max_spawn_depth` is
1282    /// hit; an Operator-issued call starts at depth 0.
1283    pub async fn start_task(
1284        &self,
1285        token: &CapToken,
1286        spec: TaskSpec,
1287    ) -> Result<StepId, EngineError> {
1288        self.verify_token(token, Verb::StartTask).await?;
1289        let task_id = StepId::new();
1290        let initial_directive = spec.initial_directive.clone();
1291        let task_id_clone = task_id.clone();
1292        let fp = token.fingerprint();
1293        let max_depth = self.inner.cfg.max_spawn_depth;
1294        self.with_state("start_task", move |s| {
1295            // Recursive swarm depth gate (recursion guard):
1296            // Worker tokens carry CapTokenRecord.parent_task_id. Give the
1297            // child parent's spawn_depth + 1; if it exceeds `max`, raise an
1298            // error. Operator tokens (parent_task_id=None) start at depth 0.
1299            let parent_depth_opt = s
1300                .tokens
1301                .get(&fp)
1302                .and_then(|rec| rec.task_id.as_ref())
1303                .and_then(|tid| s.tasks.get(tid))
1304                .map(|t| t.spawn_depth);
1305            let depth = match parent_depth_opt {
1306                Some(d) => {
1307                    if d + 1 >= max_depth {
1308                        return Err(EngineError::SpawnDepthExceeded {
1309                            current: d + 1,
1310                            max: max_depth,
1311                        });
1312                    }
1313                    d + 1
1314                }
1315                None => 0,
1316            };
1317
1318            let mut task = TaskState::new(task_id_clone.clone(), spec);
1319            task.spawn_depth = depth;
1320            s.tasks.insert(task_id_clone.clone(), task);
1321            s.prompts
1322                .insert((task_id_clone.clone(), 1), initial_directive);
1323            // Link to the owner session (only Operator tokens match; Worker tokens have no session).
1324            if let Some(sess) = s.sessions.values_mut().find(|sess| sess.token_fp == fp) {
1325                sess.owned_task_ids.push(task_id_clone.clone());
1326            }
1327            s.push_event(Event::TaskCreated {
1328                task_id: task_id_clone.clone(),
1329            });
1330            Ok::<(), EngineError>(())
1331        })
1332        .await??;
1333        let _ = self.inner.event_tx.send(Event::TaskCreated {
1334            task_id: task_id.clone(),
1335        });
1336        Ok(task_id)
1337    }
1338
1339    /// Fetch a snapshot of `TaskState` for `task_id`, subject to the
1340    /// task-ownership gate (see `verify_token_for_task`).
1341    pub async fn read_task_state(
1342        &self,
1343        token: &CapToken,
1344        task_id: &StepId,
1345    ) -> Result<TaskState, EngineError> {
1346        self.verify_token_for_task(token, Verb::ReadTaskState, task_id)
1347            .await?;
1348        let task_id = task_id.clone();
1349        self.with_state("read_task_state", move |s| {
1350            s.tasks
1351                .get(&task_id)
1352                .cloned()
1353                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
1354        })
1355        .await?
1356    }
1357
1358    /// Mark `task_id` as `Cancelled` and wake any caller blocked in
1359    /// `poll_task` for it.
1360    pub async fn cancel_task(&self, token: &CapToken, task_id: &StepId) -> Result<(), EngineError> {
1361        self.verify_token_for_task(token, Verb::CancelTask, task_id)
1362            .await?;
1363        let tid = task_id.clone();
1364        self.with_state("cancel_task", move |s| {
1365            let task = s
1366                .tasks
1367                .get_mut(&tid)
1368                .ok_or_else(|| EngineError::TaskNotFound(tid.to_string()))?;
1369            task.status = TaskStatus::Cancelled;
1370            task.updated_at = now_unix();
1371            s.push_event(Event::TaskCancelled {
1372                task_id: tid.clone(),
1373            });
1374            Ok::<(), EngineError>(())
1375        })
1376        .await??;
1377        self.wake_task(task_id).await?;
1378        Ok(())
1379    }
1380
1381    /// Dispatch a single attempt through the given `spawner`.
1382    ///
1383    /// The lock is only held for snapshot capture; the actual spawn and
1384    /// completion await happen outside the lock (R3 discipline).
1385    ///
1386    /// Sits on the Domain side of the Data / Domain split. The dispatch
1387    /// path itself does not touch big response bodies — those flow through
1388    /// the Data plane (`output_store` module + sink / input_inject
1389    /// `SpawnerLayer`s) around this method.
1390    ///
1391    /// The caller does the compile plus `service::linker::link` and
1392    /// carries the same stack through each dispatch. Because the spawner
1393    /// is passed per-request rather than looked up from engine-global
1394    /// state, parallel requests against a single `Engine` instance
1395    /// (different Blueprints, different spawners) do not race.
1396    ///
1397    /// `run_id`, when `Some` (issue #13 run_id propagation —
1398    /// `EngineDispatcher` threads it in from its `RunContext`), is
1399    /// inserted into `Ctx.meta.runtime["run_id"]` (a plain JSON string)
1400    /// alongside `worker_handle`, so `Operator::execute` implementations
1401    /// (e.g. `WSOperatorSession`) can read it back and surface it to the
1402    /// worker (Spawn directive / prompt). `None` (every pre-existing
1403    /// caller / test) omits the key entirely — unchanged behavior.
1404    pub async fn dispatch_attempt_with(
1405        &self,
1406        token: &CapToken,
1407        task_id: &StepId,
1408        spawner: &Arc<dyn SpawnerAdapter>,
1409        run_id: Option<&RunId>,
1410    ) -> Result<DispatchOutcome, EngineError> {
1411        self.verify_token(token, Verb::DispatchAttempt).await?;
1412        let task_id = task_id.clone();
1413
1414        // 1) Under the lock: increment the attempt number, mark Running, snapshot the
1415        //    prompt, and pull `operator_info` from the session so we can inject it into Ctx.
1416        let fp = token.fingerprint();
1417        let tid_for_prep = task_id.clone();
1418        let (attempt, agent, session_snapshot, step_ctx) = self
1419            .with_state("dispatch.prep", move |s| {
1420                let task = s
1421                    .tasks
1422                    .get_mut(&tid_for_prep)
1423                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1424                task.attempt += 1;
1425                task.status = TaskStatus::Running;
1426                task.updated_at = now_unix();
1427                // The spawner pulls the prompt via engine.fetch_prompt. In prep,
1428                // if the prompts table has no entry for this attempt yet,
1429                // fall back and insert `initial_directive` so the subsequent
1430                // fetch_prompt succeeds.
1431                let attempt = task.attempt;
1432                let initial = task.spec.initial_directive.clone();
1433                s.prompts
1434                    .entry((tid_for_prep.clone(), attempt))
1435                    .or_insert(initial);
1436                let task = s
1437                    .tasks
1438                    .get(&tid_for_prep)
1439                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1440                let agent = task.spec.agent.clone();
1441                // GH #21 Phase 2: re-read `TaskSpec.step_ctx` on EVERY
1442                // attempt (not cached once at start_task) so retries and
1443                // Run-rekicks all carry the Step tier through to Ctx —
1444                // see TaskSpec.step_ctx's doc.
1445                let step_ctx = task.spec.step_ctx.clone();
1446                // Session snapshot (looked up by token nonce). When no session
1447                // exists (worker token invoked directly / test injection), fall
1448                // back to None → default OperatorInfo.
1449                let sess_clone = s
1450                    .sessions
1451                    .values()
1452                    .find(|sess| sess.token_fp == fp)
1453                    .cloned();
1454                Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx))
1455            })
1456            .await??;
1457        // BridgeRegistry lookup + per-agent OperatorKind cascade.
1458        let operator_info = match session_snapshot {
1459            Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1460            None => OperatorInfo::default(),
1461        };
1462
1463        // 2) Outside the lock: worker token mint + spawn.
1464        //
1465        // Session-style mint (max_uses=None). Within one attempt the worker is
1466        // expected to hit `verify_token + fetch_prompt + fetch_data + post_result`
1467        // multiple times in order, so `one_time` would exhaust the token on the
1468        // very first verb. Capability is guarded by (a) the role × verb gate and
1469        // (b) the short TTL (1800s).
1470        let worker_token = self.inner.signer.session(
1471            format!("worker-of-{task_id}"),
1472            Role::Worker,
1473            vec!["*".into()],
1474            Duration::from_secs(1800),
1475        );
1476        let worker_fp = worker_token.fingerprint();
1477        let task_id_for_worker = task_id.clone();
1478        let worker_token_for_store = worker_token.clone();
1479        self.with_state("dispatch.mint_worker", move |s| {
1480            s.tokens.insert(
1481                worker_fp,
1482                CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1483            );
1484        })
1485        .await?;
1486
1487        // Mint a short handle (`wh-XXXXXXXX`) and register it in worker_handles.
1488        // Used by the simplified Bearer path for SubAgents (short-handle form
1489        // avoids base64 copy-paste incidents).
1490        let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1491
1492        let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1493        ctx.operator = operator_info; // activates MainAIMiddleware / Senior bridge
1494        ctx.meta
1495            .runtime
1496            .insert("worker_handle".to_string(), Value::String(worker_handle));
1497        if let Some(rid) = run_id {
1498            ctx.meta
1499                .runtime
1500                .insert(RUN_ID_KEY.to_string(), Value::String(rid.to_string()));
1501        }
1502        // GH #21 Phase 2: the Step tier's resolved context bundle (from
1503        // `TaskSpec.step_ctx`, re-read every attempt above) — consumed by
1504        // `AgentContextMiddleware`, which unpacks its keys ahead of the
1505        // Agent / BP-global tiers.
1506        if let Some(step_ctx) = step_ctx {
1507            ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1508        }
1509
1510        let worker = spawner
1511            .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1512            .await
1513            .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1514
1515        // 3) Outside the lock: await worker.join() (signal-only). WorkerError is
1516        //    stringified. The value is fetched via output_tail (sink path).
1517        let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1518
1519        // Pull the last Final from output_tail and use it as the value. GH
1520        // #36 ST1 (named multi-part worker output): also fold every
1521        // `Artifact` the WORKER ITSELF staged on the same tail (via
1522        // `stage_worker_artifact_trusted` / `POST /v1/worker/artifact`)
1523        // into a `"parts"` object keyed by name — event order,
1524        // last-write-wins per name (a name staged twice overwrites,
1525        // mirroring `HashMap`/`Map` insert semantics, not an accumulating
1526        // list). `worker_artifact_names_for` is the allowlist that scopes
1527        // this to the worker's own opt-in parts — an `Artifact` some OTHER
1528        // producer appended to this same tail (e.g.
1529        // `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding)
1530        // is left untouched (see `fold_final_and_parts`'s doc). When at
1531        // least one part was staged, the BP-chain value becomes `{"out":
1532        // <final value>, "parts": {...}}`; zero parts staged (the
1533        // pre-GH-#36 case, and every non-opt-in step) leaves the value
1534        // exactly the plain `Final` value, byte-identical to before this
1535        // change.
1536        let value_ok: Result<(Value, bool), String> = match signal_result {
1537            Ok(()) => {
1538                let tail = self.output_tail(&task_id, attempt).await;
1539                let staged_names = self.worker_artifact_names_for(&task_id, attempt).await;
1540                fold_final_and_parts(&tail, &staged_names)
1541                    .ok_or_else(|| "no Final in output_tail".to_string())
1542            }
1543            Err(msg) => Err(msg),
1544        };
1545
1546        // 4) Under the lock: apply (split the borrow scope so push_event and task mut can co-exist).
1547        let outcome = self
1548            .with_state("dispatch.apply", |s| {
1549                if !s.tasks.contains_key(&task_id) {
1550                    return Err(EngineError::TaskNotFound(task_id.to_string()));
1551                }
1552                match value_ok {
1553                    Ok((value, ok)) => {
1554                        // GH #76 Skip tier: a Final with ok=true carrying the
1555                        // skip-marker sentinel is a Skip tier completion,
1556                        // not an ordinary Pass. TaskStatus stays `Pass`
1557                        // (the worker itself completed successfully);
1558                        // the Skip signal rides on DispatchOutcome so
1559                        // EngineDispatcher::dispatch can route it to
1560                        // the flow-continuation-without-binding-write
1561                        // sentinel path.
1562                        let skip_inner = if ok { unwrap_skip_marker(&value) } else { None };
1563                        let pass = ok;
1564                        {
1565                            let task = s.tasks.get_mut(&task_id).unwrap();
1566                            task.last_result = Some(value.clone());
1567                            task.updated_at = now_unix();
1568                            task.status = if pass {
1569                                TaskStatus::Pass
1570                            } else {
1571                                TaskStatus::Blocked
1572                            };
1573                        }
1574                        s.push_event(Event::TaskAttemptCompleted {
1575                            task_id: task_id.clone(),
1576                            attempt,
1577                            result: value.clone(),
1578                        });
1579                        if let Some(inner) = skip_inner {
1580                            s.push_event(Event::TaskPass {
1581                                task_id: task_id.clone(),
1582                                result: value.clone(),
1583                            });
1584                            Ok::<_, EngineError>(DispatchOutcome::Skip(inner))
1585                        } else if pass {
1586                            s.push_event(Event::TaskPass {
1587                                task_id: task_id.clone(),
1588                                result: value.clone(),
1589                            });
1590                            Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1591                        } else {
1592                            s.push_event(Event::TaskBlocked {
1593                                task_id: task_id.clone(),
1594                                result: value.clone(),
1595                            });
1596                            Ok(DispatchOutcome::Blocked(value))
1597                        }
1598                    }
1599                    Err(msg) => {
1600                        let task = s.tasks.get_mut(&task_id).unwrap();
1601                        task.status = TaskStatus::Blocked;
1602                        task.updated_at = now_unix();
1603                        Err(EngineError::DispatchFailed(msg))
1604                    }
1605                }
1606            })
1607            .await??;
1608
1609        // event broadcast (outside the lock — push_event feeds the in-memory tail; broadcast is a separate path).
1610        let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1611            task_id: task_id.clone(),
1612            attempt,
1613            result: match &outcome {
1614                DispatchOutcome::Pass(v)
1615                | DispatchOutcome::Blocked(v)
1616                | DispatchOutcome::Skip(v) => v.clone(),
1617                _ => Value::Null,
1618            },
1619        });
1620
1621        // Wake any callers waiting in poll_task.
1622        self.wake_task(&task_id).await?;
1623
1624        Ok(outcome)
1625    }
1626
1627    /// Dispatch a single attempt, opt-in to the replay-log Core primitive
1628    /// ([`crate::store::replay`]) via `run_ctx`.
1629    ///
1630    /// This is the [`Self::dispatch_attempt_with`] sibling used by callers
1631    /// that carry a `RunContext` with `replay_store` / `replay_cursor`
1632    /// populated. Behavior versus the plain `dispatch_attempt_with`:
1633    ///
1634    /// - **`run_ctx.replay_cursor` is `Some` AND the cursor has a matching
1635    ///   `(step_ref, input_hash, occurrence)` row** — the stored value is
1636    ///   returned verbatim as `DispatchOutcome::Pass(v)`; the `Adapter`
1637    ///   (spawner + worker) is never touched. The task's `attempt` is
1638    ///   still bumped and `TaskStatus` set to `Pass`, so downstream state
1639    ///   (`task.last_result`, `TaskAttemptCompleted` / `TaskPass` events,
1640    ///   `wake_task`) fires the same way an ordinary Pass would.
1641    /// - **Miss (or `replay_cursor: None`)** — the ordinary spawn path
1642    ///   runs. When `run_ctx.replay_store` is `Some` AND the outcome is
1643    ///   `Pass`, one `ReplayEntry` is appended carrying the whole `Ctx`
1644    ///   snapshot (with `operator` dropped by `#[serde(skip)]`) plus the
1645    ///   `step_output` value. `Blocked` / `Err` outcomes are never
1646    ///   logged — a partial-failure row would poison the replay path
1647    ///   after a subsequent successful retry.
1648    ///
1649    /// `run_ctx: None` collapses to the same behavior as
1650    /// `dispatch_attempt_with(token, task_id, spawner, None)` — no run
1651    /// tracing, no replay.
1652    pub async fn dispatch_attempt_with_run_ctx(
1653        &self,
1654        token: &CapToken,
1655        task_id: &StepId,
1656        spawner: &Arc<dyn SpawnerAdapter>,
1657        run_ctx: Option<&RunContext>,
1658    ) -> Result<DispatchOutcome, EngineError> {
1659        self.verify_token(token, Verb::DispatchAttempt).await?;
1660        let task_id = task_id.clone();
1661
1662        // 1) Under the lock: prep (bump attempt, snapshot agent/directive).
1663        let fp = token.fingerprint();
1664        let tid_for_prep = task_id.clone();
1665        let (attempt, agent, session_snapshot, step_ctx, initial_directive) = self
1666            .with_state("dispatch_run_ctx.prep", move |s| {
1667                let task = s
1668                    .tasks
1669                    .get_mut(&tid_for_prep)
1670                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1671                task.attempt += 1;
1672                task.status = TaskStatus::Running;
1673                task.updated_at = now_unix();
1674                let attempt = task.attempt;
1675                let initial = task.spec.initial_directive.clone();
1676                s.prompts
1677                    .entry((tid_for_prep.clone(), attempt))
1678                    .or_insert(initial.clone());
1679                let task = s
1680                    .tasks
1681                    .get(&tid_for_prep)
1682                    .ok_or_else(|| EngineError::TaskNotFound(tid_for_prep.to_string()))?;
1683                let agent = task.spec.agent.clone();
1684                let step_ctx = task.spec.step_ctx.clone();
1685                let sess_clone = s
1686                    .sessions
1687                    .values()
1688                    .find(|sess| sess.token_fp == fp)
1689                    .cloned();
1690                Ok::<_, EngineError>((attempt, agent, sess_clone, step_ctx, initial))
1691            })
1692            .await??;
1693
1694        let operator_info = match session_snapshot {
1695            Some(sess) => self.resolve_operator_info(&sess, &agent).await,
1696            None => OperatorInfo::default(),
1697        };
1698
1699        // 2) Compute the replay key from step_ref (= agent) + hashed input.
1700        //    Occurrence comes from the cursor's per-key counter (bumped
1701        //    once per dispatch, so a loop that re-visits the same step
1702        //    with the same input gets 0, 1, 2, … distinct rows).
1703        let step_ref = agent.clone();
1704        let input_hash = match run_ctx.and_then(|rc| rc.binding_digests.get(&step_ref)) {
1705            Some(binding_digest) => hash_input_value(&serde_json::json!({
1706                "input": initial_directive,
1707                "binding_digest": binding_digest,
1708            })),
1709            None => hash_input_value(&initial_directive),
1710        };
1711        let (replay_hit_value, occurrence) = if let Some(rc) = run_ctx {
1712            if let Some(cursor) = &rc.replay_cursor {
1713                let mut guard = cursor.lock().expect("replay cursor mutex poisoned");
1714                let occ = guard.next_occurrence(&step_ref, &input_hash);
1715                let hit = guard.find(&step_ref, &input_hash, occ);
1716                (hit, occ)
1717            } else {
1718                (None, 0)
1719            }
1720        } else {
1721            (None, 0)
1722        };
1723
1724        // 3) Build the Ctx that (a) either the spawner will see on a miss,
1725        //    or (b) we log alongside the replay row.
1726        let mut ctx = Ctx::new(task_id.clone(), attempt, agent.clone());
1727        ctx.operator = operator_info;
1728        if let Some(rc) = run_ctx {
1729            ctx.meta
1730                .runtime
1731                .insert(RUN_ID_KEY.to_string(), Value::String(rc.run_id.to_string()));
1732        }
1733        if let Some(step_ctx) = step_ctx {
1734            ctx.meta.runtime.insert(STEP_CTX_KEY.to_string(), step_ctx);
1735        }
1736
1737        // 4) Replay-hit shortcut: skip the spawn+join, return stored value.
1738        let was_replay_hit = replay_hit_value.is_some();
1739        let value_ok: Result<(Value, bool), String> = if let Some(stored) = replay_hit_value {
1740            tracing::info!(
1741                task_id = %task_id,
1742                step_ref = %step_ref,
1743                occurrence = occurrence,
1744                "replayed from log; worker dispatch skipped"
1745            );
1746            Ok((stored, true))
1747        } else {
1748            // 5) Ordinary spawn path — mint a worker token+handle, run the
1749            //    spawner, join, and pull the last Final from output_tail.
1750            let worker_token = self.inner.signer.session(
1751                format!("worker-of-{task_id}"),
1752                Role::Worker,
1753                vec!["*".into()],
1754                Duration::from_secs(1800),
1755            );
1756            let worker_fp = worker_token.fingerprint();
1757            let task_id_for_worker = task_id.clone();
1758            let worker_token_for_store = worker_token.clone();
1759            self.with_state("dispatch_run_ctx.mint_worker", move |s| {
1760                s.tokens.insert(
1761                    worker_fp,
1762                    CapTokenRecord::from_worker_token(worker_token_for_store, task_id_for_worker),
1763                );
1764            })
1765            .await?;
1766            let worker_handle = self.mint_worker_handle(worker_token.fingerprint()).await?;
1767            ctx.meta
1768                .runtime
1769                .insert("worker_handle".to_string(), Value::String(worker_handle));
1770
1771            let worker = spawner
1772                .spawn(self, &ctx, task_id.clone(), attempt, worker_token)
1773                .await
1774                .map_err(|e| EngineError::DispatchFailed(e.to_string()))?;
1775            let signal_result: Result<(), String> = worker.join().await.map_err(|e| e.to_string());
1776            match signal_result {
1777                Ok(()) => {
1778                    let tail = self.output_tail(&task_id, attempt).await;
1779                    let staged_names = self.worker_artifact_names_for(&task_id, attempt).await;
1780                    fold_final_and_parts(&tail, &staged_names)
1781                        .ok_or_else(|| "no Final in output_tail".to_string())
1782                }
1783                Err(msg) => Err(msg),
1784            }
1785        };
1786
1787        // 6) Apply — mirrors `dispatch_attempt_with`'s apply arm exactly
1788        //    (task.last_result / status update + TaskAttemptCompleted /
1789        //    TaskPass / TaskBlocked events).
1790        let outcome = self
1791            .with_state("dispatch_run_ctx.apply", |s| {
1792                if !s.tasks.contains_key(&task_id) {
1793                    return Err(EngineError::TaskNotFound(task_id.to_string()));
1794                }
1795                match value_ok {
1796                    Ok((value, ok)) => {
1797                        // GH #76 Skip tier: Skip tier detection — same shape
1798                        // as the sibling `dispatch_attempt_with` apply
1799                        // arm above. See that arm's comment for the
1800                        // TaskStatus / Event / DispatchOutcome contract.
1801                        let skip_inner = if ok { unwrap_skip_marker(&value) } else { None };
1802                        let pass = ok;
1803                        {
1804                            let task = s.tasks.get_mut(&task_id).unwrap();
1805                            task.last_result = Some(value.clone());
1806                            task.updated_at = now_unix();
1807                            task.status = if pass {
1808                                TaskStatus::Pass
1809                            } else {
1810                                TaskStatus::Blocked
1811                            };
1812                        }
1813                        s.push_event(Event::TaskAttemptCompleted {
1814                            task_id: task_id.clone(),
1815                            attempt,
1816                            result: value.clone(),
1817                        });
1818                        if let Some(inner) = skip_inner {
1819                            s.push_event(Event::TaskPass {
1820                                task_id: task_id.clone(),
1821                                result: value.clone(),
1822                            });
1823                            Ok::<_, EngineError>(DispatchOutcome::Skip(inner))
1824                        } else if pass {
1825                            s.push_event(Event::TaskPass {
1826                                task_id: task_id.clone(),
1827                                result: value.clone(),
1828                            });
1829                            Ok::<_, EngineError>(DispatchOutcome::Pass(value))
1830                        } else {
1831                            s.push_event(Event::TaskBlocked {
1832                                task_id: task_id.clone(),
1833                                result: value.clone(),
1834                            });
1835                            Ok(DispatchOutcome::Blocked(value))
1836                        }
1837                    }
1838                    Err(msg) => {
1839                        let task = s.tasks.get_mut(&task_id).unwrap();
1840                        task.status = TaskStatus::Blocked;
1841                        task.updated_at = now_unix();
1842                        Err(EngineError::DispatchFailed(msg))
1843                    }
1844                }
1845            })
1846            .await??;
1847
1848        // 7) On MISS + Pass + replay_store present, append a replay row.
1849        //    Replay-HIT rows are already logged from the original run and
1850        //    must never be double-logged (Core primitive contract). A
1851        //    secondary-persistence failure here (`tracing::warn!` +
1852        //    swallow) matches the `run_ctx.run_store.append_step_entry`
1853        //    convention in `EngineDispatcher::dispatch`: it must not mask
1854        //    the primary dispatch outcome the caller already has in hand.
1855        if !was_replay_hit {
1856            if let (Some(rc), DispatchOutcome::Pass(v)) = (run_ctx, &outcome) {
1857                if let Some(store) = &rc.replay_store {
1858                    match ReplayEntry::from_completion(
1859                        rc.run_id.clone(),
1860                        step_ref.clone(),
1861                        input_hash.clone(),
1862                        occurrence,
1863                        &ctx,
1864                        v,
1865                    ) {
1866                        Ok(entry) => {
1867                            if let Err(e) = store.append(entry).await {
1868                                tracing::warn!(
1869                                    run_id = %rc.run_id,
1870                                    step_ref = %step_ref,
1871                                    occurrence = occurrence,
1872                                    error = %e,
1873                                    "dispatch_attempt_with_run_ctx: replay_store.append failed"
1874                                );
1875                            }
1876                        }
1877                        Err(e) => {
1878                            tracing::warn!(
1879                                run_id = %rc.run_id,
1880                                step_ref = %step_ref,
1881                                occurrence = occurrence,
1882                                error = %e,
1883                                "dispatch_attempt_with_run_ctx: ReplayEntry encode failed"
1884                            );
1885                        }
1886                    }
1887                }
1888            }
1889        }
1890
1891        let _ = self.inner.event_tx.send(Event::TaskAttemptCompleted {
1892            task_id: task_id.clone(),
1893            attempt,
1894            result: match &outcome {
1895                DispatchOutcome::Pass(v)
1896                | DispatchOutcome::Blocked(v)
1897                | DispatchOutcome::Skip(v) => v.clone(),
1898                _ => Value::Null,
1899            },
1900        });
1901
1902        self.wake_task(&task_id).await?;
1903
1904        Ok(outcome)
1905    }
1906
1907    // ═══════════════════════════════════════════════════════════════════════
1908    // Worker-side API (= prompt / data fetch + result post)
1909    // ═══════════════════════════════════════════════════════════════════════
1910
1911    /// Fetch the directive/prompt `Value` for `task_id`'s current attempt.
1912    /// Falls back to `initial_directive` when no prompt has been recorded
1913    /// yet for that attempt. Returns the `Value` end-to-end (issue #18);
1914    /// the render down to `String` happens only at the two consumer
1915    /// boundaries — the Worker HTTP path (`fetch_worker_payload*` →
1916    /// `WorkerPayload.prompt: String`) and the WS Spawn frame text
1917    /// render (`operator_ws::session`).
1918    pub async fn fetch_prompt(
1919        &self,
1920        token: &CapToken,
1921        task_id: &StepId,
1922    ) -> Result<Value, EngineError> {
1923        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1924            .await?;
1925        let task_id = task_id.clone();
1926        self.with_state("fetch_prompt", move |s| {
1927            let task = s
1928                .tasks
1929                .get(&task_id)
1930                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
1931            s.prompts
1932                .get(&(task_id.clone(), task.attempt.max(1)))
1933                .cloned()
1934                .ok_or_else(|| {
1935                    EngineError::ResourceNotFound(format!(
1936                        "prompt({}, attempt={})",
1937                        task_id, task.attempt
1938                    ))
1939                })
1940        })
1941        .await?
1942    }
1943
1944    /// Combined fetch for `HTTP /v1/worker/prompt`: returns `prompt` +
1945    /// (optional) `system` + `agent` + `attempt` in a single round trip.
1946    /// The verb gate reuses `FetchPrompt` — same semantics as "the worker
1947    /// pulls its task input".
1948    ///
1949    /// `system` is the value written by `OperatorSpawner::spawn` through
1950    /// `bake_worker_system_prompt` when it ran; otherwise `None` (no
1951    /// profile present, or the bake never happened).
1952    pub async fn fetch_worker_payload(
1953        &self,
1954        token: &CapToken,
1955        task_id: &StepId,
1956    ) -> Result<crate::types::WorkerPayload, EngineError> {
1957        self.verify_token_for_task(token, Verb::FetchPrompt, task_id)
1958            .await?;
1959        let task_id_clone = task_id.clone();
1960        let mut payload = self
1961            .with_state("fetch_worker_payload", move |s| {
1962                let task = s
1963                    .tasks
1964                    .get(&task_id_clone)
1965                    .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
1966                let attempt = task.attempt.max(1);
1967                let prompt = s
1968                    .prompts
1969                    .get(&(task_id_clone.clone(), attempt))
1970                    .cloned()
1971                    .ok_or_else(|| {
1972                        EngineError::ResourceNotFound(format!(
1973                            "prompt({}, attempt={})",
1974                            task_id_clone, attempt
1975                        ))
1976                    })?;
1977                let system = s
1978                    .systems
1979                    .get(&(task_id_clone.clone(), attempt))
1980                    .cloned()
1981                    .unwrap_or(None);
1982                let agent = task.spec.agent.clone();
1983                let context = s
1984                    .agent_ctx
1985                    .get(&(task_id_clone.clone(), attempt))
1986                    .map(|e| e.view.clone());
1987                Ok::<_, EngineError>(crate::types::WorkerPayload {
1988                    task_id: task_id_clone.clone(),
1989                    attempt,
1990                    agent,
1991                    prompt: render_directive_to_string(&prompt),
1992                    system,
1993                    context,
1994                    system_ref: None,
1995                })
1996            })
1997            .await??;
1998        self.apply_system_ref_threshold(&mut payload).await?;
1999        Ok(payload)
2000    }
2001
2002    /// Fetch a worker payload via a short handle. Skips token verification
2003    /// and returns `prompt` + `system` + `agent` + `attempt` in a thin
2004    /// path. The caller is expected to have already resolved `task_id`
2005    /// via `task_id_from_handle` — the handle's presence in
2006    /// `worker_handles` means it was minted server-side and is therefore
2007    /// trusted.
2008    pub async fn fetch_worker_payload_trusted(
2009        &self,
2010        task_id: &StepId,
2011    ) -> Result<crate::types::WorkerPayload, EngineError> {
2012        let task_id_clone = task_id.clone();
2013        let mut payload = self
2014            .with_state("fetch_worker_payload_trusted", move |s| {
2015                let task = s
2016                    .tasks
2017                    .get(&task_id_clone)
2018                    .ok_or_else(|| EngineError::TaskNotFound(task_id_clone.to_string()))?;
2019                let attempt = task.attempt.max(1);
2020                let prompt = s
2021                    .prompts
2022                    .get(&(task_id_clone.clone(), attempt))
2023                    .cloned()
2024                    .ok_or_else(|| {
2025                        EngineError::ResourceNotFound(format!(
2026                            "prompt({}, attempt={})",
2027                            task_id_clone, attempt
2028                        ))
2029                    })?;
2030                let system = s
2031                    .systems
2032                    .get(&(task_id_clone.clone(), attempt))
2033                    .cloned()
2034                    .unwrap_or(None);
2035                let agent = task.spec.agent.clone();
2036                let context = s
2037                    .agent_ctx
2038                    .get(&(task_id_clone.clone(), attempt))
2039                    .map(|e| e.view.clone());
2040                Ok::<_, EngineError>(crate::types::WorkerPayload {
2041                    task_id: task_id_clone.clone(),
2042                    attempt,
2043                    agent,
2044                    prompt: render_directive_to_string(&prompt),
2045                    system,
2046                    context,
2047                    system_ref: None,
2048                })
2049            })
2050            .await??;
2051        self.apply_system_ref_threshold(&mut payload).await?;
2052        Ok(payload)
2053    }
2054
2055    /// GH #31: shared threshold-branch tail for
2056    /// [`Self::fetch_worker_payload`] / [`Self::fetch_worker_payload_trusted`].
2057    /// Both build a raw `WorkerPayload` inside `with_state` with `system`
2058    /// populated as before and `system_ref: None`; this runs *outside* any
2059    /// lock (R3 — `SystemRefMode::File`'s `tokio::fs` write is a genuine
2060    /// `.await`, which `with_state`'s sync-closure contract forbids inside
2061    /// the lock) and rewrites `payload.system` / `payload.system_ref` in
2062    /// place per `SystemRefConfig.threshold_bytes`: over-threshold clears
2063    /// `system` and populates `system_ref`; at-or-under-threshold leaves
2064    /// `system` as-is and `system_ref` stays `None`. A no-op when
2065    /// `payload.system` is already `None` (no `system_prompt` was baked).
2066    async fn apply_system_ref_threshold(
2067        &self,
2068        payload: &mut crate::types::WorkerPayload,
2069    ) -> Result<(), EngineError> {
2070        let Some(rendered) = payload.system.take() else {
2071            return Ok(());
2072        };
2073        let cfg = self.cfg().system_ref.clone();
2074        if rendered.len() <= cfg.threshold_bytes {
2075            payload.system = Some(rendered);
2076            return Ok(());
2077        }
2078        use sha2::Digest;
2079        let size_bytes = rendered.len() as u64;
2080        let sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
2081        let task_id = &payload.task_id;
2082        let attempt = payload.attempt;
2083        let system_ref = match cfg.mode {
2084            crate::types::SystemRefMode::Http => crate::types::SystemRef {
2085                // The engine has no knowledge of scheme/host here — see
2086                // `SystemRefMode::Http`'s doc for who fills that in.
2087                uri: format!("/v1/worker/prompt/system?task_id={task_id}&attempt={attempt}"),
2088                sha256,
2089                size_bytes,
2090                mode: crate::types::SystemRefMode::Http,
2091            },
2092            crate::types::SystemRefMode::File => {
2093                tokio::fs::create_dir_all(&cfg.store_dir).await?;
2094                let path = cfg.store_dir.join(format!("{task_id}-{attempt}.md"));
2095                tokio::fs::write(&path, rendered.as_bytes()).await?;
2096                crate::types::SystemRef {
2097                    uri: format!("file://{}", path.display()),
2098                    sha256,
2099                    size_bytes,
2100                    mode: crate::types::SystemRefMode::File,
2101                }
2102            }
2103        };
2104        payload.system = None;
2105        payload.system_ref = Some(system_ref);
2106        Ok(())
2107    }
2108
2109    /// GH #83: unconditionally materialize the baked system prompt for
2110    /// `(task_id, attempt)` to a file and return its path — the value
2111    /// source of the `{system_file}` placeholder in a `SubprocessDef`
2112    /// template. Unlike [`Self::apply_system_ref_threshold`] (whose
2113    /// `SystemRefMode::File` write only fires over
2114    /// `SystemRefConfig.threshold_bytes`, a behavior this helper does NOT
2115    /// touch), a template that names `{system_file}` needs a real path
2116    /// regardless of size, so the write here is unconditional. Reuses the
2117    /// same store dir and `{task_id}-{attempt}.md` naming as the File
2118    /// mode, so both paths converge on one on-disk identity per attempt.
2119    ///
2120    /// `Ok(None)` = no system prompt was baked for this attempt (the
2121    /// caller decides whether that is fail-loud — the Subprocess spawn
2122    /// path treats a `{system_file}` reference without a baked system as
2123    /// a `SpawnError`).
2124    pub async fn materialize_system_file(
2125        &self,
2126        task_id: &StepId,
2127        attempt: u32,
2128    ) -> Result<Option<std::path::PathBuf>, EngineError> {
2129        let key = (task_id.clone(), attempt);
2130        let rendered = self
2131            .with_state("materialize_system_file", move |s| {
2132                s.systems.get(&key).cloned().unwrap_or(None)
2133            })
2134            .await?;
2135        let Some(rendered) = rendered else {
2136            return Ok(None);
2137        };
2138        let cfg = self.cfg().system_ref.clone();
2139        tokio::fs::create_dir_all(&cfg.store_dir).await?;
2140        let path = cfg.store_dir.join(format!("{task_id}-{attempt}.md"));
2141        tokio::fs::write(&path, rendered.as_bytes()).await?;
2142        Ok(Some(path))
2143    }
2144
2145    /// Returns the effective [`mlua_swarm_schema::ContextPolicy`]
2146    /// `AgentContextMiddleware` resolved and snapshotted for `(task_id,
2147    /// attempt)` at spawn time (the same policy already applied to that
2148    /// key's `EngineState.agent_ctx` entry's `.view`, GH #23 fold).
2149    /// Pass-all (`ContextPolicy::default()`) when no entry exists — either
2150    /// a pre-ST5 spawn, or a spawner stack that never layered
2151    /// `AgentContextMiddleware` (fail-open, mirroring [`Self::output_tail`]'s
2152    /// "no entry = empty default" convention).
2153    ///
2154    /// `crates/mlua-swarm-server/src/worker.rs`'s `GET /v1/worker/prompt`
2155    /// handler reads this back to filter `WorkerPayload.context.steps` via
2156    /// `ContextPolicy::allows_step`, without re-deriving the policy from
2157    /// the Blueprint at fetch time (`projection-adapter` ST5).
2158    pub async fn context_policy_for(
2159        &self,
2160        task_id: &StepId,
2161        attempt: u32,
2162    ) -> mlua_swarm_schema::ContextPolicy {
2163        let key = (task_id.clone(), attempt);
2164        self.with_state("context_policy_for", move |s| {
2165            s.agent_ctx
2166                .get(&key)
2167                .map(|e| e.policy.clone())
2168                .unwrap_or_default()
2169        })
2170        .await
2171        .unwrap_or_default()
2172    }
2173
2174    /// GH #23: returns the Blueprint-wide
2175    /// [`crate::core::step_naming::StepNaming`] table snapshotted for
2176    /// `task_id` (the same `Arc` `crate::blueprint::EngineDispatcher::dispatch`
2177    /// stashed into `EngineState.step_namings` at dispatch time —
2178    /// `Self::start_task`'s `StepId`, not the `TaskId` work item). `None`
2179    /// when no entry exists — either the dispatcher was never given a
2180    /// `StepNaming` (`EngineDispatcher::with_step_naming` not called) or
2181    /// the lock could not be acquired; callers are expected to fall back
2182    /// to the pre-GH-#23 runtime union rule in that case (subtask-2/3
2183    /// consumers).
2184    pub async fn step_naming_for(
2185        &self,
2186        task_id: &StepId,
2187    ) -> Option<Arc<crate::core::step_naming::StepNaming>> {
2188        let key = task_id.clone();
2189        self.with_state("step_naming_for", move |s| {
2190            s.step_namings.get(&key).cloned()
2191        })
2192        .await
2193        .ok()
2194        .flatten()
2195    }
2196
2197    /// GH #27 (follow-up to #23): returns the Blueprint-wide
2198    /// [`crate::core::projection_placement::ProjectionPlacement`] resolver
2199    /// snapshotted for `task_id` (the same `Arc`
2200    /// `crate::blueprint::EngineDispatcher::dispatch` stashed into
2201    /// `EngineState.projection_placements` at dispatch time — mirroring
2202    /// [`Self::step_naming_for`]'s contract exactly). `None` when no entry
2203    /// exists — either the dispatcher was never given a
2204    /// `ProjectionPlacement` (`EngineDispatcher::with_projection_placement`
2205    /// not called) or the lock could not be acquired; callers are expected
2206    /// to fall back to `ProjectionPlacement::default()` (byte-compat with
2207    /// the pre-#27 hardcoded layout) in that case.
2208    pub async fn projection_placement_for(
2209        &self,
2210        task_id: &StepId,
2211    ) -> Option<Arc<crate::core::projection_placement::ProjectionPlacement>> {
2212        let key = task_id.clone();
2213        self.with_state("projection_placement_for", move |s| {
2214            s.projection_placements.get(&key).cloned()
2215        })
2216        .await
2217        .ok()
2218        .flatten()
2219    }
2220
2221    /// Record normalized per-attempt worker stats reported by a worker
2222    /// boundary (spawner fold site / result captor / `POST
2223    /// /v1/worker/submit`). Last-write-wins per `(task_id, attempt)`.
2224    /// Best-effort: a state-lock failure is logged and swallowed —
2225    /// stats are observational and must never fail the attempt that
2226    /// produced them. Drained by [`Self::take_worker_stats`] at the
2227    /// dispatcher's outcome fold.
2228    pub async fn record_worker_stats(
2229        &self,
2230        task_id: &StepId,
2231        attempt: u32,
2232        stats: crate::store::trace::WorkerStats,
2233    ) {
2234        if stats.is_empty() {
2235            return;
2236        }
2237        let key = (task_id.clone(), attempt);
2238        if let Err(e) = self
2239            .with_state("record_worker_stats", move |s| {
2240                s.worker_stats.insert(key, stats);
2241            })
2242            .await
2243        {
2244            tracing::warn!(
2245                task_id = %task_id,
2246                attempt,
2247                error = %e,
2248                "record_worker_stats failed (swallowed — stats are observational)"
2249            );
2250        }
2251    }
2252
2253    /// Drain every recorded worker-stats entry for `task_id`, returning
2254    /// the highest-attempt one (the attempt whose outcome the dispatcher
2255    /// is folding). Removing ALL of the task's entries — not just the
2256    /// returned one — keeps retries from leaking earlier attempts into
2257    /// `EngineState` for the process lifetime.
2258    pub async fn take_worker_stats(
2259        &self,
2260        task_id: &StepId,
2261    ) -> Option<(u32, crate::store::trace::WorkerStats)> {
2262        let key_task = task_id.clone();
2263        self.with_state("take_worker_stats", move |s| {
2264            let attempts: Vec<u32> = s
2265                .worker_stats
2266                .keys()
2267                .filter(|(tid, _)| *tid == key_task)
2268                .map(|(_, a)| *a)
2269                .collect();
2270            let mut best: Option<(u32, crate::store::trace::WorkerStats)> = None;
2271            for attempt in attempts {
2272                if let Some(stats) = s.worker_stats.remove(&(key_task.clone(), attempt)) {
2273                    if best.as_ref().map(|(a, _)| attempt >= *a).unwrap_or(true) {
2274                        best = Some((attempt, stats));
2275                    }
2276                }
2277            }
2278            best
2279        })
2280        .await
2281        .ok()
2282        .flatten()
2283    }
2284
2285    /// Returns the [`crate::store::trace::TraceHandle`] the dispatcher
2286    /// registered for `task_id`'s in-flight step, if any — the
2287    /// pervasive-insertion read port middlewares (and any other writer
2288    /// holding an `Engine`) use to append their own trace kinds. `None`
2289    /// = no trace rail for this dispatch (RunContext without a trace
2290    /// handle, or the step already folded).
2291    pub async fn trace_handle(&self, task_id: &StepId) -> Option<crate::store::trace::TraceHandle> {
2292        let key = task_id.clone();
2293        self.with_state("trace_handle", move |s| s.trace_handles.get(&key).cloned())
2294            .await
2295            .ok()
2296            .flatten()
2297    }
2298
2299    /// Register (or clear, with `None`) the per-dispatch trace handle
2300    /// for `task_id`. Called only by `EngineDispatcher::dispatch` —
2301    /// insert before spawn, clear after the outcome fold. Best-effort:
2302    /// registry failures are swallowed (trace is observational).
2303    pub(crate) async fn set_trace_handle(
2304        &self,
2305        task_id: &StepId,
2306        handle: Option<crate::store::trace::TraceHandle>,
2307    ) {
2308        let key = task_id.clone();
2309        let _ = self
2310            .with_state("set_trace_handle", move |s| match handle {
2311                Some(h) => {
2312                    s.trace_handles.insert(key, h);
2313                }
2314                None => {
2315                    s.trace_handles.remove(&key);
2316                }
2317            })
2318            .await;
2319    }
2320
2321    /// Returns the [`crate::core::agent_context::AgentContextView`]
2322    /// snapshotted for `(task_id, attempt)`, if `AgentContextMiddleware`
2323    /// stashed one — the same lookup [`Self::fetch_worker_payload`] /
2324    /// [`Self::fetch_worker_payload_trusted`] perform inline, exposed
2325    /// standalone for callers that only need the view (not a full
2326    /// `WorkerPayload`) — e.g. the HTTP debug-plane `GET
2327    /// /v1/tasks/:id/runs/:run/steps*` handlers resolving a
2328    /// materialized-file root for a step *other than* the one currently
2329    /// fetching its own prompt (`projection-adapter` ST5).
2330    pub async fn agent_context_for(
2331        &self,
2332        task_id: &StepId,
2333        attempt: u32,
2334    ) -> Option<crate::core::agent_context::AgentContextView> {
2335        let key = (task_id.clone(), attempt);
2336        self.with_state("agent_context_for", move |s| {
2337            s.agent_ctx.get(&key).map(|e| e.view.clone())
2338        })
2339        .await
2340        .ok()
2341        .flatten()
2342    }
2343
2344    /// Read the current attempt number for a task (server-side lookup, no
2345    /// token verification). Used on `HTTP /v1/worker/result` when the
2346    /// worker omits `attempt` and the server has to fill it in.
2347    pub async fn task_attempt(&self, task_id: &StepId) -> Result<u32, EngineError> {
2348        let task_id = task_id.clone();
2349        self.with_state("task_attempt", move |s| {
2350            s.tasks
2351                .get(&task_id)
2352                .map(|t| t.attempt)
2353                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))
2354        })
2355        .await?
2356    }
2357
2358    /// Server-side admin API that lets `OperatorSpawner::spawn` bake the
2359    /// rendered `system_prompt` into engine state. There is no verb gate
2360    /// — the only expected caller is inside the spawner. SubAgents fetch
2361    /// this alongside the prompt on the `/v1/worker/prompt` path.
2362    pub async fn bake_worker_system_prompt(
2363        &self,
2364        task_id: &StepId,
2365        attempt: u32,
2366        system: Option<String>,
2367    ) -> Result<(), EngineError> {
2368        let task_id = task_id.clone();
2369        self.with_state("bake_worker_system_prompt", move |s| {
2370            // GH #31: record this agent's most-recently-baked render size
2371            // before `system` is moved into `s.systems.insert` below. Same
2372            // `s.tasks.get(&task_id)` → `.spec.agent` lookup pattern
2373            // `fetch_worker_payload` uses (see its doc for why this keying
2374            // is load-bearing for a later `bp_doctor` route).
2375            if let Some(rendered) = system.as_ref() {
2376                if let Some(agent) = s.tasks.get(&task_id).map(|t| t.spec.agent.clone()) {
2377                    s.agent_render_sizes.insert(agent, rendered.len());
2378                }
2379            }
2380            s.systems.insert((task_id, attempt), system);
2381        })
2382        .await?;
2383        Ok(())
2384    }
2385
2386    /// GH #31: the most-recently-baked `system_prompt` render size (in
2387    /// bytes) observed for `agent_name`, if `bake_worker_system_prompt` has
2388    /// ever recorded one — last-write-wins across every `(task_id,
2389    /// attempt)` dispatch of that agent. `None` when no `system_prompt`
2390    /// has ever been baked for this agent name. Read by the `bp_doctor`
2391    /// route this subtask's follow-up adds.
2392    pub async fn agent_last_rendered_size(&self, agent_name: &str) -> Option<usize> {
2393        let agent_name = agent_name.to_string();
2394        self.with_state("agent_last_rendered_size", move |s| {
2395            s.agent_render_sizes.get(&agent_name).copied()
2396        })
2397        .await
2398        .ok()
2399        .flatten()
2400    }
2401
2402    /// GH #31: plain read-through of the baked `system` string for
2403    /// `(task_id, attempt)` from `EngineState.systems`, with no threshold
2404    /// branching. Backs `GET /v1/worker/prompt/system` (the `Http`-mode
2405    /// fetch target `system_ref.uri` points at) — that route needs the
2406    /// exact raw bytes to serve as the response body for the client's
2407    /// sha256 verification, not a `WorkerPayload`-wrapped value.
2408    ///
2409    /// Distinct from `apply_system_ref_threshold` (private, mutates an
2410    /// already-built `WorkerPayload` in place after full construction):
2411    /// this accessor has no threshold logic and is `pub` so
2412    /// `mlua-swarm-server`'s `worker` module can call it directly.
2413    ///
2414    /// Returns `Ok(None)` if no baked system exists for that `(task_id,
2415    /// attempt)` (either the task/attempt has no entry in `s.systems`, or
2416    /// the entry is present but stores `None`) — the caller maps this to
2417    /// a 404.
2418    pub async fn raw_system_prompt(
2419        &self,
2420        task_id: &StepId,
2421        attempt: u32,
2422    ) -> Result<Option<String>, EngineError> {
2423        let task_id = task_id.clone();
2424        self.with_state("raw_system_prompt", move |s| {
2425            s.systems.get(&(task_id, attempt)).cloned().unwrap_or(None)
2426        })
2427        .await
2428    }
2429
2430    /// Fetch an arbitrary named resource previously stored via
2431    /// `set_resource`. Not task-scoped — any valid token with the
2432    /// `FetchData` verb may read any key.
2433    pub async fn fetch_data(&self, token: &CapToken, key: &str) -> Result<Value, EngineError> {
2434        self.verify_token(token, Verb::FetchData).await?;
2435        let key = key.to_string();
2436        self.with_state("fetch_data", move |s| {
2437            s.resources
2438                .get(&key)
2439                .cloned()
2440                .ok_or(EngineError::ResourceNotFound(key))
2441        })
2442        .await?
2443    }
2444
2445    // ───────────────────────────────────────────────────────────────────────
2446    // Output path.
2447    // ───────────────────────────────────────────────────────────────────────
2448
2449    /// Send one output event from inside a `SpawnerAdapter` or worker.
2450    /// Structuring is assumed to be complete by the time we cross the
2451    /// `SpawnerAdapter` boundary; this API just appends to the
2452    /// `OutputStore`, pushes to the `EventLog`, and (for `Final`) emits
2453    /// the `TaskAttemptCompleted` event.
2454    ///
2455    /// This is Domain-side plumbing: it feeds the engine's verdict flow,
2456    /// not the Data-plane store in the `output_store` module. It also
2457    /// does not wake the dispatch path — that is done through the
2458    /// spawner's completion oneshot when the worker terminates.
2459    ///
2460    /// # Submit-time projection sink (subtask-4 / ST2 rework)
2461    ///
2462    /// A `Final` event additionally fans out to the submit-time projection
2463    /// sink ([`Self::materialize_final_submission`]): (a) when
2464    /// [`Self::set_output_store`] has wired a Data-plane
2465    /// [`crate::store::output::OutputStore`], the event is dual-written
2466    /// there (`producer_agent` = `TaskState.spec.agent`, resolved to its
2467    /// GH #23 canonical projection name — see below), and (b) when this
2468    /// task's spawn ran through `AgentContextMiddleware` (so
2469    /// `EngineState.agent_ctx` has a `.view.work_dir` / `.view.project_root`
2470    /// for it), the value is additionally materialized to the
2471    /// [`crate::core::projection_placement::ProjectionPlacement`]
2472    /// resolver's target (byte-compat default layout
2473    /// `<root>/workspace/tasks/<task_id>/ctx/<canonical_agent>.md`) — see
2474    /// `crate::core::projection`'s module doc.
2475    ///
2476    /// **GH #23 subtask-2 (canonical sink):** both writes above key off the
2477    /// canonical name — `Engine::step_naming_for(task_id)`'s
2478    /// `StepNaming::canonical_of_producer(producer_agent)` when a table was
2479    /// snapshotted for this task (`EngineDispatcher::with_step_naming`),
2480    /// else `producer_agent` unchanged (fail-open, byte-identical to
2481    /// pre-GH-#23 behavior — see [`crate::core::step_naming`]'s module
2482    /// doc).
2483    ///
2484    /// **Invariants** (Subtask 4): (1) this sink is fail-open — an
2485    /// unresolved root, an unconfigured `OutputStore`, or either one
2486    /// erroring, only logs a `tracing::warn!` and never turns this
2487    /// `Ok(())` into an `Err`; (2) the wired `OutputStore` stays the single
2488    /// source of truth for cross-step queries — the materialized file is a
2489    /// projection of it, not a second store; (3) core does not depend on
2490    /// `mlua-swarm-server` — everything this sink touches
2491    /// (`crate::store::output` / `crate::core::projection`) already lives
2492    /// in this crate.
2493    ///
2494    /// # `Artifact` dual-write (GH #34 subtask-3 gap fix)
2495    ///
2496    /// An `Artifact` event ALSO fans out to the Data-plane, via
2497    /// [`Self::materialize_artifact_submission`] — general-form: every
2498    /// `Artifact` submitted through this API dual-writes, no name-prefix
2499    /// gate. Unlike `Final`, the dual-write key is the artifact's own
2500    /// `name` field, verbatim — NOT resolved through the GH #23 canonical
2501    /// `StepNaming` table. An artifact's `name` IS its identity (mirrors
2502    /// [`crate::store::output::OutputStore::get_latest_by_name`]'s doc),
2503    /// so no canonicalization applies. Same fail-open discipline as
2504    /// `Final` (Invariant 1 above), but `Artifact` does NOT drive the
2505    /// file-materialize half (b) — artifact findings (e.g.
2506    /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"`) are observational
2507    /// sidecar data, not a step's own submission a work_dir/project_root
2508    /// projection needs to track. `Progress` / `Partial` events are
2509    /// unaffected — no behavior change.
2510    pub async fn submit_output(
2511        &self,
2512        token: &crate::types::CapToken,
2513        task_id: &StepId,
2514        attempt: u32,
2515        event: crate::worker::output::OutputEvent,
2516    ) -> Result<(), EngineError> {
2517        self.verify_token_for_task(token, crate::types::Verb::EmitOutput, task_id)
2518            .await?;
2519        // GH #51 — completion-time verdict-contract enforcement, embedded
2520        // choke point 2 of 2 (see `Self::verdict_contract_completion_check`'s
2521        // doc). Guarded to `Final` only — the ONLY `OutputEvent` variant a
2522        // verdict contract's completion can meaningfully address; this
2523        // guard is defensive (this function is empirically called with
2524        // `Final` only today, both from `worker.rs`'s `worker_result` and
2525        // from `operator.rs`'s WS fallback) but costs nothing and protects
2526        // against a future non-`Final` caller. Runs BEFORE the
2527        // `output_tail` write immediately below: on `Err`, this returns
2528        // immediately and the write never happens — a rejected value
2529        // never reaches `output_tail` / the flow ctx.
2530        if let crate::worker::output::OutputEvent::Final { content, ok } = &event {
2531            let comparable_value = content_ref_to_comparable_string(content.clone());
2532            self.verdict_contract_completion_check(task_id, attempt, *ok, &comparable_value)
2533                .await?;
2534        }
2535        let task_id_for_apply = task_id.clone();
2536        let event_clone = event.clone();
2537        self.with_state("submit_output", move |s| {
2538            s.output_store
2539                .entry((task_id_for_apply.clone(), attempt))
2540                .or_default()
2541                .push(event_clone.clone());
2542            s.push_event(crate::core::state::Event::WorkerOutput {
2543                task_id: task_id_for_apply,
2544                attempt,
2545                event: event_clone,
2546            });
2547        })
2548        .await?;
2549        match &event {
2550            crate::worker::output::OutputEvent::Final { content, ok } => {
2551                self.materialize_final_submission(task_id, attempt, content, *ok)
2552                    .await?;
2553            }
2554            crate::worker::output::OutputEvent::Artifact { name, content } => {
2555                self.materialize_artifact_submission(task_id, attempt, name, content)
2556                    .await?;
2557            }
2558            _ => {}
2559        }
2560        Ok(())
2561    }
2562
2563    /// Submit-time projection sink (subtask-4 / ST2 rework) shared by
2564    /// [`Self::submit_output`] and [`Self::submit_worker_result_trusted`].
2565    /// Best-effort / fail-open throughout (see `submit_output`'s doc
2566    /// Invariants): every failure path only `tracing::warn!`s and returns.
2567    ///
2568    /// Reads `(producer_agent, view)` via one read-only [`Self::with_state`]
2569    /// call — `producer_agent` off `TaskState.spec.agent`, `view` (the
2570    /// full [`crate::core::agent_context::AgentContextView`]) off
2571    /// `EngineState.agent_ctx[(task_id, attempt)]`, the same snapshot
2572    /// `crate::middleware::agent_context::AgentContextMiddleware` writes at
2573    /// spawn time — then does its actual (dual-write / file-write) work
2574    /// *outside* that lock, so a slow disk write or Data-plane store call
2575    /// never holds up unrelated `Engine::with_state` callers. `root` itself
2576    /// is resolved from `view` AFTER the lock via
2577    /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
2578    /// (GH #27, follow-up to #23) — the SAME resolver
2579    /// [`Self::step_naming_for`]'s sibling accessor
2580    /// [`Self::projection_placement_for`] snapshotted at dispatch time, so
2581    /// this sink's root-preference / fallback order is identical to the
2582    /// server read-back and the spawn-time pointer.
2583    async fn materialize_final_submission(
2584        &self,
2585        task_id: &StepId,
2586        attempt: u32,
2587        content: &crate::worker::output::ContentRef,
2588        ok: bool,
2589    ) -> Result<(), EngineError> {
2590        let server_policy = self.cfg().check_policy;
2591        let task_id_for_lookup = task_id.clone();
2592        let lookup = self
2593            .with_state("materialize_final_submission.lookup", move |s| {
2594                let entry = s.tasks.get(&task_id_for_lookup);
2595                let producer_agent = entry.map(|t| t.spec.agent.clone());
2596                let task_policy = entry.and_then(|t| t.spec.check_policy);
2597                let view = s
2598                    .agent_ctx
2599                    .get(&(task_id_for_lookup.clone(), attempt))
2600                    .map(|e| e.view.clone());
2601                (producer_agent, task_policy, view)
2602            })
2603            .await;
2604        // Per-task `TaskSpec.check_policy` (ST1c) wins
2605        // over the server-wide `EngineCfg.check_policy` when set — a
2606        // per-run override forwarded from the launch entry point (see
2607        // `TaskLaunchRequest.check_policy` /
2608        // `TaskLaunchInput.check_policy`). `None` leaves the server
2609        // default in effect (backward compat).
2610        let policy = lookup
2611            .as_ref()
2612            .ok()
2613            .and_then(|(_, tp, _)| *tp)
2614            .unwrap_or(server_policy);
2615        let (producer_agent, view) = match lookup.map(|(pa, _, view)| (pa, view)) {
2616            Ok(pair) => pair,
2617            Err(err) => {
2618                if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2619                    tracing::warn!(
2620                        %task_id,
2621                        error = %err,
2622                        "submit-time projection sink: state lookup failed; skipping (fail-open)"
2623                    );
2624                }
2625                apply_check_policy(
2626                    policy,
2627                    "submit-time projection sink: state lookup",
2628                    "state lookup failed; skipping (fail-open)",
2629                )?;
2630                return Ok(());
2631            }
2632        };
2633        let Some(producer_agent) = producer_agent else {
2634            // Defensive only: `task_id` is always a just-looked-up task at
2635            // every real call site. No task, no addressable producer name
2636            // — nothing to project. Not gated by `CheckPolicy` — a missing
2637            // task is an intentional early-exit path, not a fail-open
2638            // condition to surface.
2639            return Ok(());
2640        };
2641        let placement = self
2642            .projection_placement_for(task_id)
2643            .await
2644            .unwrap_or_default();
2645        let root = view.and_then(|v| placement.resolve_root(&v));
2646
2647        // GH #23 subtask-2: resolve `producer_agent` to its canonical
2648        // projection name via the Blueprint-wide `StepNaming` table
2649        // snapshotted at dispatch time (`Engine::step_naming_for`). Both
2650        // write paths below ((a) data-plane, (b) file stem) use the
2651        // *canonical* name — `StepNaming::canonical_of_producer` returns
2652        // `producer_agent` unchanged for undeclared steps (byte-identical
2653        // to pre-GH-#23 behavior), and `None` (no table for this
2654        // `task_id`, e.g. a spawn that never went through
2655        // `EngineDispatcher::with_step_naming`) is a defensive fail-open
2656        // to the raw `producer_agent`, same discipline as the rest of this
2657        // sink.
2658        let canonical_agent = self
2659            .step_naming_for(task_id)
2660            .await
2661            .and_then(|naming| {
2662                naming
2663                    .canonical_of_producer(&producer_agent)
2664                    .map(str::to_string)
2665            })
2666            .unwrap_or_else(|| producer_agent.clone());
2667
2668        // (a) Data-plane dual-write, when an OutputStore backend is wired.
2669        if let Some(store) = self.output_store_backend() {
2670            if let Err(err) = store
2671                .append(
2672                    task_id.as_str(),
2673                    attempt,
2674                    &canonical_agent,
2675                    crate::worker::output::OutputEvent::Final {
2676                        content: content.clone(),
2677                        ok,
2678                    },
2679                    Vec::new(),
2680                )
2681                .await
2682            {
2683                if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2684                    tracing::warn!(
2685                        %task_id,
2686                        agent = %producer_agent,
2687                        canonical = %canonical_agent,
2688                        error = %err,
2689                        "submit-time projection sink: OutputStore dual-write failed (fail-open)"
2690                    );
2691                }
2692                apply_check_policy(
2693                    policy,
2694                    "submit-time projection sink: OutputStore dual-write",
2695                    "OutputStore dual-write failed (fail-open)",
2696                )?;
2697            }
2698        }
2699
2700        // (b) File materialize, when a root resolved.
2701        let Some(root) = root else {
2702            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2703                tracing::warn!(
2704                    %task_id,
2705                    agent = %producer_agent,
2706                    canonical = %canonical_agent,
2707                    "submit-time projection sink: no work_dir/project_root resolved; skipping file materialize (fail-open)"
2708                );
2709            }
2710            apply_check_policy(
2711                policy,
2712                "submit-time projection sink: file materialize",
2713                "no work_dir/project_root resolved; skipping file materialize (fail-open)",
2714            )?;
2715            return Ok(());
2716        };
2717        let value = match content {
2718            crate::worker::output::ContentRef::Inline { value } => value.clone(),
2719            crate::worker::output::ContentRef::FileRef {
2720                path,
2721                mime,
2722                size_hint,
2723            } => serde_json::json!({
2724                "file_ref": path.to_string_lossy(),
2725                "mime": mime,
2726                "size_hint": size_hint,
2727            }),
2728        };
2729        let key = crate::core::projection::ProjectionKey {
2730            task_id: task_id.to_string(),
2731            run_id: None,
2732            step: Some(canonical_agent.clone()),
2733            path: None,
2734        };
2735        let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2736            root,
2737            (*placement).clone(),
2738        );
2739        if let Err(err) = adapter.materialize_submission(&key, &value, attempt, ok) {
2740            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2741                tracing::warn!(
2742                    %task_id,
2743                    agent = %producer_agent,
2744                    canonical = %canonical_agent,
2745                    error = %err,
2746                    "submit-time projection sink: file materialize failed (fail-open)"
2747                );
2748            }
2749            apply_check_policy(
2750                policy,
2751                "submit-time projection sink: file materialize",
2752                "file materialize failed (fail-open)",
2753            )?;
2754        }
2755        Ok(())
2756    }
2757
2758    /// Submit-time projection sink for `OutputEvent::Artifact` (GH #34
2759    /// subtask-3, later extended to drive the file half too). Two halves, the
2760    /// [`Self::materialize_final_submission`] mirror for staged named parts:
2761    ///
2762    /// - **Data-plane dual-write** — when [`Self::set_output_store`] has
2763    ///   wired a [`crate::store::output::OutputStore`], the artifact
2764    ///   dual-writes there under its own `name`, verbatim (general form:
2765    ///   every `Artifact` staged via [`Self::submit_output`] /
2766    ///   [`Self::stage_worker_artifact_trusted`] materializes this way, no
2767    ///   name-prefix gate).
2768    /// - **File materialize** — when a `root` resolves off the spawn-time
2769    ///   [`crate::core::agent_context::AgentContextView`], the part's
2770    ///   content is written raw to `<ctx-dir>/<name>` via
2771    ///   [`crate::core::projection::FileProjectionAdapter::materialize_part`].
2772    ///   That file is the IN file the *next* Agent step reads: materializing
2773    ///   a Step's OUTPUT to disk is the
2774    ///   [`crate::core::projection::FileProjectionAdapter`]'s
2775    ///   responsibility, and a staged named part is as much an OUTPUT the
2776    ///   next step consumes as a `Final` is — so the sink materializes it
2777    ///   too, rather than leaving parts Data-plane-only.
2778    ///
2779    /// Unlike the Final sink, no `StepNaming` canonicalization is applied:
2780    /// an artifact's `name` already IS the key both halves address (it
2781    /// names the file directly, extension included — `plan.md` — so
2782    /// `materialize_part` writes it verbatim, not through the `<stem>.md`
2783    /// synthesis the Final sink's canonical-agent path uses).
2784    ///
2785    /// Fail-open throughout, the same `check_policy` cascade as
2786    /// [`Self::materialize_final_submission`]: a per-task lookup error falls
2787    /// back to the server default (and a `None` view ⇒ the file half's
2788    /// unresolved-root path), an unconfigured `OutputStore` skips the
2789    /// dual-write, an unresolved root skips the file half, and a
2790    /// dual-write / file-write / name-guard error only `tracing::warn!`s
2791    /// (`Silent` suppresses even that) before applying [`apply_check_policy`]
2792    /// (`Strict` surfaces an [`EngineError`], `Warn` / `Silent` return
2793    /// `Ok(())`) — a staged part never turns a would-have-succeeded submit
2794    /// into a failure under the default policy.
2795    async fn materialize_artifact_submission(
2796        &self,
2797        task_id: &StepId,
2798        attempt: u32,
2799        name: &str,
2800        content: &crate::worker::output::ContentRef,
2801    ) -> Result<(), EngineError> {
2802        // Per-task `TaskSpec.check_policy` override + the `AgentContextView`
2803        // snapshot, resolved in ONE read-only `with_state` (the same lock
2804        // the policy lookup already needed — no extra `with_state` for the
2805        // view). Silent per-task lookup failure (`with_state` error) falls
2806        // back to the server-wide default and a `None` view (⇒ the file
2807        // half's own unresolved-root fail-open path); this sink never
2808        // surfaces the lookup error itself as a step failure.
2809        let server_policy = self.cfg().check_policy;
2810        let task_id_for_lookup = task_id.clone();
2811        let lookup = self
2812            .with_state("materialize_artifact_submission.lookup", move |s| {
2813                let task_policy = s
2814                    .tasks
2815                    .get(&task_id_for_lookup)
2816                    .and_then(|t| t.spec.check_policy);
2817                let view = s
2818                    .agent_ctx
2819                    .get(&(task_id_for_lookup.clone(), attempt))
2820                    .map(|e| e.view.clone());
2821                (task_policy, view)
2822            })
2823            .await
2824            .ok();
2825        let policy = lookup
2826            .as_ref()
2827            .and_then(|(tp, _)| *tp)
2828            .unwrap_or(server_policy);
2829        let view = lookup.and_then(|(_, view)| view);
2830
2831        // (a) Data-plane dual-write, when an OutputStore backend is wired —
2832        // the artifact's own `name` is its Data-plane key (no
2833        // canonicalization, unlike the Final sink's `StepNaming`
2834        // resolution).
2835        if let Some(store) = self.output_store_backend() {
2836            if let Err(err) = store
2837                .append(
2838                    task_id.as_str(),
2839                    attempt,
2840                    name,
2841                    crate::worker::output::OutputEvent::Artifact {
2842                        name: name.to_string(),
2843                        content: content.clone(),
2844                    },
2845                    Vec::new(),
2846                )
2847                .await
2848            {
2849                if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2850                    tracing::warn!(
2851                        %task_id,
2852                        artifact = %name,
2853                        error = %err,
2854                        "submit-time projection sink: OutputStore dual-write failed for Artifact (fail-open)"
2855                    );
2856                }
2857                apply_check_policy(
2858                    policy,
2859                    "submit-time projection sink: Artifact OutputStore dual-write",
2860                    "OutputStore dual-write failed for Artifact (fail-open)",
2861                )?;
2862            }
2863        }
2864
2865        // (b) File materialize, when a root resolved — writes the staged
2866        // part raw to `<ctx-dir>/<name>`, the IN file the next Agent step
2867        // reads (see `FileProjectionAdapter::materialize_part`'s doc for
2868        // why raw / why the name is verbatim). A name-guard violation lands
2869        // on the same fail-open path as any other write error below.
2870        let placement = self
2871            .projection_placement_for(task_id)
2872            .await
2873            .unwrap_or_default();
2874        let Some(root) = view.and_then(|v| placement.resolve_root(&v)) else {
2875            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2876                tracing::warn!(
2877                    %task_id,
2878                    artifact = %name,
2879                    "submit-time projection sink: no work_dir/project_root resolved; skipping part file materialize (fail-open)"
2880                );
2881            }
2882            apply_check_policy(
2883                policy,
2884                "submit-time projection sink: part file materialize",
2885                "no work_dir/project_root resolved; skipping part file materialize (fail-open)",
2886            )?;
2887            return Ok(());
2888        };
2889        let value = match content {
2890            crate::worker::output::ContentRef::Inline { value } => value.clone(),
2891            crate::worker::output::ContentRef::FileRef {
2892                path,
2893                mime,
2894                size_hint,
2895            } => serde_json::json!({
2896                "file_ref": path.to_string_lossy(),
2897                "mime": mime,
2898                "size_hint": size_hint,
2899            }),
2900        };
2901        let adapter = crate::core::projection::FileProjectionAdapter::with_placement(
2902            root,
2903            (*placement).clone(),
2904        );
2905        if let Err(err) = adapter.materialize_part(task_id.as_str(), name, &value) {
2906            if !matches!(policy, crate::core::config::CheckPolicy::Silent) {
2907                tracing::warn!(
2908                    %task_id,
2909                    artifact = %name,
2910                    error = %err,
2911                    "submit-time projection sink: part file materialize failed (fail-open)"
2912                );
2913            }
2914            apply_check_policy(
2915                policy,
2916                "submit-time projection sink: part file materialize",
2917                "part file materialize failed (fail-open)",
2918            )?;
2919        }
2920        Ok(())
2921    }
2922
2923    /// Snapshot the entire output tail for a given `(task_id, attempt)`.
2924    /// Used by the dispatch path when pulling `Final`, and by observers
2925    /// reading the trace.
2926    pub async fn output_tail(
2927        &self,
2928        task_id: &StepId,
2929        attempt: u32,
2930    ) -> Vec<crate::worker::output::OutputEvent> {
2931        let key = (task_id.clone(), attempt);
2932        self.with_state("output_tail", move |s| {
2933            s.output_store.get(&key).cloned().unwrap_or_default()
2934        })
2935        .await
2936        .unwrap_or_default()
2937    }
2938
2939    /// Record an interim `last_result` for `task_id` without changing its
2940    /// `status`. Distinct from the terminal `Final` output event handled
2941    /// through `submit_output` / `dispatch_attempt_with`.
2942    pub async fn post_result(
2943        &self,
2944        token: &CapToken,
2945        task_id: &StepId,
2946        result: Value,
2947    ) -> Result<(), EngineError> {
2948        self.verify_token_for_task(token, Verb::PostResult, task_id)
2949            .await?;
2950        let task_id = task_id.clone();
2951        let result_clone = result.clone();
2952        self.with_state("post_result", move |s| {
2953            let task = s
2954                .tasks
2955                .get_mut(&task_id)
2956                .ok_or_else(|| EngineError::TaskNotFound(task_id.to_string()))?;
2957            task.last_result = Some(result_clone);
2958            task.updated_at = now_unix();
2959            Ok::<(), EngineError>(())
2960        })
2961        .await??;
2962        Ok(())
2963    }
2964
2965    /// Store a named resource value, retrievable later via `fetch_data`.
2966    /// No token is required — this is a server-side/admin-style setter
2967    /// (mirrors `bake_worker_system_prompt`).
2968    pub async fn set_resource(
2969        &self,
2970        key: impl Into<String>,
2971        value: Value,
2972    ) -> Result<(), EngineError> {
2973        let key = key.into();
2974        self.with_state("set_resource", move |s| {
2975            s.resources.insert(key, value);
2976        })
2977        .await?;
2978        Ok(())
2979    }
2980
2981    // ═══════════════════════════════════════════════════════════════════════
2982    // Senior suspend / resume
2983    // ═══════════════════════════════════════════════════════════════════════
2984
2985    /// Ask a question of the Senior, mark the task `Suspended`, and
2986    /// return a `ResumeKey`. The suspended state persists until another
2987    /// task calls `resume(key, answer)`.
2988    ///
2989    /// Resume-side waiting is `Notify`-based, so a caller (typically
2990    /// MainAI) can detach, reattach from a different process, and still
2991    /// pull the answer out via `await_resume(key, timeout)` — the answer
2992    /// is stored inside `EngineState`.
2993    pub async fn query_senior(
2994        &self,
2995        token: &CapToken,
2996        task_id: &StepId,
2997        question: Value,
2998    ) -> Result<ResumeKey, EngineError> {
2999        self.verify_token(token, Verb::QuerySenior).await?;
3000        let task_id = task_id.clone();
3001        let key = ResumeKey::for_senior(&task_id);
3002        let task_notify = self
3003            .with_state("query_senior.notify_ensure", |s| {
3004                s.ensure_task_notify(&task_id)
3005            })
3006            .await?;
3007
3008        let key_clone = key.clone();
3009        let task_id_inner = task_id.clone();
3010        let question_clone = question.clone();
3011        self.with_state("query_senior.suspend", move |s| {
3012            let task = s
3013                .tasks
3014                .get_mut(&task_id_inner)
3015                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
3016            task.status = TaskStatus::Suspended;
3017            task.suspended_on = Some(key_clone.clone());
3018            task.updated_at = now_unix();
3019            s.pending_resumes
3020                .insert(key_clone.clone(), ResumePending::new());
3021            s.push_event(Event::SeniorQueried {
3022                task_id: task_id_inner.clone(),
3023                question: question_clone.clone(),
3024            });
3025            s.push_event(Event::TaskSuspended {
3026                task_id: task_id_inner.clone(),
3027                key: key_clone.clone(),
3028            });
3029            Ok::<(), EngineError>(())
3030        })
3031        .await??;
3032
3033        // Notify callers waiting for a task status change (Running → Suspended).
3034        task_notify.notify_waiters();
3035
3036        let _ = self
3037            .inner
3038            .event_tx
3039            .send(Event::SeniorQueried { task_id, question });
3040        Ok(key)
3041    }
3042
3043    /// Store the answer for a `ResumeKey` in `EngineState` and wake the
3044    /// waiting caller via `Notify`. Also flips the suspended task's
3045    /// status back to `Running` and fires the per-task notifier.
3046    pub async fn resume(&self, key: ResumeKey, answer: Value) -> Result<(), EngineError> {
3047        let answer_for_state = answer.clone();
3048        let answer_for_event = answer.clone();
3049        let key_clone = key.clone();
3050        let (notify, task_notify, task_id_opt) = self
3051            .with_state("resume.set", move |s| {
3052                let pending = s
3053                    .pending_resumes
3054                    .get_mut(&key_clone)
3055                    .ok_or(EngineError::ResumeKeyNotFound)?;
3056                pending.answer = Some(answer_for_state);
3057                let notify = pending.notify.clone();
3058
3059                let task_id = s
3060                    .tasks
3061                    .iter()
3062                    .find(|(_, t)| t.suspended_on.as_ref() == Some(&key_clone))
3063                    .map(|(id, _)| id.clone());
3064
3065                let task_notify = task_id.as_ref().map(|tid| s.ensure_task_notify(tid));
3066
3067                if let Some(tid) = &task_id {
3068                    if let Some(task) = s.tasks.get_mut(tid) {
3069                        task.suspended_on = None;
3070                        task.status = TaskStatus::Running;
3071                        task.updated_at = now_unix();
3072                    }
3073                    s.push_event(Event::TaskResumed {
3074                        task_id: tid.clone(),
3075                        key: key_clone.clone(),
3076                    });
3077                    s.push_event(Event::SeniorAnswered {
3078                        task_id: tid.clone(),
3079                        answer: answer_for_event.clone(),
3080                    });
3081                }
3082                Ok::<_, EngineError>((notify, task_notify, task_id))
3083            })
3084            .await??;
3085
3086        // Outside the lock: notify_waiters for both the ResumePending and task-status waits.
3087        notify.notify_waiters();
3088        if let Some(n) = task_notify {
3089            n.notify_waiters();
3090        }
3091
3092        if let Some(tid) = task_id_opt {
3093            let _ = self
3094                .inner
3095                .event_tx
3096                .send(Event::TaskResumed { task_id: tid, key });
3097        }
3098        Ok(())
3099    }
3100
3101    /// Wait for the resume answer. Even if the caller (an Operator)
3102    /// detached and reattached, the answer is available immediately here
3103    /// — if it was already stored, this returns without waiting on the
3104    /// notifier.
3105    ///
3106    /// `timeout = Duration::ZERO` performs an instant check without
3107    /// waiting.
3108    pub async fn await_resume(
3109        &self,
3110        key: ResumeKey,
3111        timeout: Duration,
3112    ) -> Result<Value, EngineError> {
3113        // (1) Under the lock: clone the notify handle and check for an existing answer.
3114        let key_clone = key.clone();
3115        let (notify, existing) = self
3116            .with_state("await_resume.snapshot", move |s| {
3117                let pending = s
3118                    .pending_resumes
3119                    .get(&key_clone)
3120                    .ok_or(EngineError::ResumeKeyNotFound)?;
3121                Ok::<_, EngineError>((pending.notify.clone(), pending.answer.clone()))
3122            })
3123            .await??;
3124
3125        // (2) If an answer has already been stored, return immediately (detach / reattach pattern).
3126        if let Some(v) = existing {
3127            return Ok(v);
3128        }
3129
3130        // (3) Outside the lock: wait on the notify with a timeout.
3131        if timeout.is_zero() {
3132            return Err(EngineError::PollTimeout);
3133        }
3134        let waited = tokio::time::timeout(timeout, notify.notified()).await;
3135        if waited.is_err() {
3136            return Err(EngineError::PollTimeout);
3137        }
3138
3139        // (4) Under the lock: re-read the answer (should be present now that we were notified).
3140        let key_clone = key.clone();
3141        self.with_state("await_resume.read", move |s| {
3142            let pending = s
3143                .pending_resumes
3144                .get(&key_clone)
3145                .ok_or(EngineError::ResumeKeyNotFound)?;
3146            pending
3147                .answer
3148                .clone()
3149                .ok_or_else(|| EngineError::Internal("notified but answer missing".into()))
3150        })
3151        .await?
3152    }
3153
3154    // ═══════════════════════════════════════════════════════════════════════
3155    // poll_task — the "wait" path that waits for task-status changes (works for long-poll and regular wait).
3156    // ═══════════════════════════════════════════════════════════════════════
3157
3158    /// Wait until the task's status **transitions to terminal or
3159    /// `Suspended`**, then return the latest `TaskState`. Returns
3160    /// immediately if the task is already in a terminal state.
3161    /// Exceeding the timeout returns `EngineError::PollTimeout`.
3162    ///
3163    /// A `hold` of `Duration::from_secs(0)` returns a snapshot immediately
3164    /// (no wait). Larger holds — tens of minutes up to days — are fine;
3165    /// the wait state is kept in memory inside the engine and does not
3166    /// degrade.
3167    pub async fn poll_task(
3168        &self,
3169        token: &CapToken,
3170        task_id: &StepId,
3171        hold: Duration,
3172    ) -> Result<TaskState, EngineError> {
3173        self.verify_token_for_task(token, Verb::PollTask, task_id)
3174            .await?;
3175        let task_id_inner = task_id.clone();
3176
3177        // (1) Under the lock: take a snapshot and clone task_notify.
3178        let (state, notify) = self
3179            .with_state("poll_task.snapshot", move |s| {
3180                let task = s
3181                    .tasks
3182                    .get(&task_id_inner)
3183                    .cloned()
3184                    .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))?;
3185                let notify = s.ensure_task_notify(&task_id_inner);
3186                Ok::<_, EngineError>((task, notify))
3187            })
3188            .await??;
3189
3190        // (2) Immediate-return condition: already terminal / Suspended (nothing left to wait on).
3191        if matches!(
3192            state.status,
3193            TaskStatus::Pass | TaskStatus::Blocked | TaskStatus::Cancelled | TaskStatus::Suspended
3194        ) {
3195            return Ok(state);
3196        }
3197        if hold.is_zero() {
3198            return Ok(state);
3199        }
3200
3201        // (3) Outside the lock: wait on Notify with a timeout.
3202        let waited = tokio::time::timeout(hold, notify.notified()).await;
3203        if waited.is_err() {
3204            return Err(EngineError::PollTimeout);
3205        }
3206
3207        // (4) Under the lock: take a fresh snapshot.
3208        let task_id_inner = task_id.clone();
3209        self.with_state("poll_task.reread", move |s| {
3210            s.tasks
3211                .get(&task_id_inner)
3212                .cloned()
3213                .ok_or_else(|| EngineError::TaskNotFound(task_id_inner.to_string()))
3214        })
3215        .await?
3216    }
3217
3218    // ═══════════════════════════════════════════════════════════════════════
3219    // Background: heartbeat miss → detach loop
3220    // ═══════════════════════════════════════════════════════════════════════
3221
3222    /// Background loop that scans sessions every `heartbeat_interval` and
3223    /// flips `attached = false` on any session whose `last_seen` exceeds
3224    /// `heartbeat_miss_threshold * interval`.
3225    ///
3226    /// The tasks themselves are kept (assuming
3227    /// `keepalive_on_idle = true`), so another client can reattach with
3228    /// the same token and resume immediately. Dropping the returned
3229    /// `JoinHandle` does not stop the loop — the handle exists so callers
3230    /// who want to abort can hold onto it.
3231    pub fn start_detach_loop(&self) -> tokio::task::JoinHandle<()> {
3232        let engine = self.clone();
3233        let cfg = self.inner.cfg.long_hold.clone();
3234        let interval = cfg.heartbeat_interval;
3235        let miss_secs = cfg.heartbeat_interval.as_secs() * cfg.heartbeat_miss_threshold as u64;
3236
3237        tokio::spawn(async move {
3238            let mut ticker = tokio::time::interval(interval);
3239            ticker.tick().await; // first tick is immediate
3240            loop {
3241                ticker.tick().await;
3242                let now = now_unix();
3243                let detached = engine
3244                    .with_state("detach_loop.scan", |s| {
3245                        let mut detached = Vec::new();
3246                        for (sid, sess) in s.sessions.iter_mut() {
3247                            if !sess.attached {
3248                                continue;
3249                            }
3250                            if now.saturating_sub(sess.last_seen) >= miss_secs {
3251                                sess.attached = false;
3252                                detached.push(sid.clone());
3253                            }
3254                        }
3255                        for sid in &detached {
3256                            s.push_event(Event::SessionDetached {
3257                                session_id: sid.clone(),
3258                            });
3259                        }
3260                        detached
3261                    })
3262                    .await
3263                    .unwrap_or_default();
3264                for sid in detached {
3265                    let _ = engine
3266                        .inner
3267                        .event_tx
3268                        .send(Event::SessionDetached { session_id: sid });
3269                }
3270            }
3271        })
3272    }
3273
3274    /// Helper: wake a task whose status has changed. Called from the
3275    /// method body outside the lock.
3276    async fn wake_task(&self, task_id: &StepId) -> Result<(), EngineError> {
3277        let task_id = task_id.clone();
3278        let notify_opt = self
3279            .with_state("wake_task.get_notify", move |s| {
3280                s.task_notifies.get(&task_id).cloned()
3281            })
3282            .await?;
3283        if let Some(n) = notify_opt {
3284            n.notify_waiters();
3285        }
3286        Ok(())
3287    }
3288}
3289
3290/// Decide what a submit-time projection sink should do at a fail-open
3291/// branch given the configured [`crate::core::config::CheckPolicy`].
3292///
3293/// Returns `Ok(())` under [`CheckPolicy::Silent`] and
3294/// [`CheckPolicy::Warn`] — the caller continues with fail-open. Returns
3295/// [`EngineError::CheckPolicyStrict`] under [`CheckPolicy::Strict`],
3296/// carrying the caller-supplied `context` (call-site identifier) and
3297/// `message` (the pre-existing warn-log message literal, preserved
3298/// verbatim for log parse compatibility).
3299///
3300/// This helper deliberately does **not** call `tracing::warn!` itself —
3301/// the caller is responsible for firing the existing warn! (with its
3302/// full structured-field payload — `%task_id`, `agent`, `canonical`,
3303/// `error`, etc.) under `Warn` mode, and for skipping the warn! under
3304/// `Silent` mode. Keeping the warn! at the call site preserves the
3305/// exact structured-field shape every existing log-parse consumer sees;
3306/// forwarding it through the helper would either drop those fields or
3307/// require a macro (deferred, see subtask-1b).
3308///
3309/// Design intent: the fail-open discipline of every submit-time
3310/// projection sink is byte-identical to the pre-`CheckPolicy` behaviour
3311/// under the default [`CheckPolicy::Warn`]. `Silent` is a per-run opt-in
3312/// to suppress noise (e.g., a caller that has already verified upstream
3313/// invariants); `Strict` is a per-run opt-in to fail loudly (e.g., a
3314/// caller that requires all parts to materialize). See
3315/// [`crate::core::config::CheckPolicy`] for the "state dirty on fail"
3316/// semantics of `Strict`.
3317pub(crate) fn apply_check_policy(
3318    policy: crate::core::config::CheckPolicy,
3319    context: &str,
3320    message: &str,
3321) -> Result<(), EngineError> {
3322    match policy {
3323        crate::core::config::CheckPolicy::Silent | crate::core::config::CheckPolicy::Warn => Ok(()),
3324        crate::core::config::CheckPolicy::Strict => Err(EngineError::CheckPolicyStrict {
3325            context: context.to_string(),
3326            message: message.to_string(),
3327        }),
3328    }
3329}
3330
3331#[cfg(test)]
3332mod check_policy_helper_tests {
3333    use super::apply_check_policy;
3334    use crate::core::config::CheckPolicy;
3335    use crate::core::errors::EngineError;
3336
3337    /// `Silent` returns `Ok(())` without producing an error. Log
3338    /// suppression (the "no `tracing::warn!`" half of the semantics) is
3339    /// enforced at the call site, not inside the helper — see the
3340    /// helper's doc comment for why.
3341    #[test]
3342    fn silent_returns_ok() {
3343        let result = apply_check_policy(CheckPolicy::Silent, "call/site", "sink message");
3344        assert!(matches!(result, Ok(())));
3345    }
3346
3347    /// `Warn` (the default) returns `Ok(())` — the caller continues
3348    /// with fail-open, having already fired its own `tracing::warn!`
3349    /// with the full structured-field payload.
3350    #[test]
3351    fn warn_returns_ok() {
3352        let result = apply_check_policy(CheckPolicy::Warn, "call/site", "sink message");
3353        assert!(matches!(result, Ok(())));
3354    }
3355
3356    /// `Strict` returns
3357    /// [`EngineError::CheckPolicyStrict`] with `context` and `message`
3358    /// copied verbatim from the caller — the completion route surfaces
3359    /// this as a step / launch error so a caller that has opted in can
3360    /// fail fast instead of proceeding with a partially-realized
3361    /// submission.
3362    #[test]
3363    fn strict_returns_error_with_context_and_message() {
3364        let result = apply_check_policy(
3365            CheckPolicy::Strict,
3366            "submit-time projection sink: file materialize",
3367            "no work_dir/project_root resolved; skipping file materialize (fail-open)",
3368        );
3369        match result {
3370            Err(EngineError::CheckPolicyStrict { context, message }) => {
3371                assert_eq!(context, "submit-time projection sink: file materialize");
3372                assert_eq!(
3373                    message,
3374                    "no work_dir/project_root resolved; skipping file materialize (fail-open)"
3375                );
3376            }
3377            other => panic!("expected CheckPolicyStrict, got {:?}", other),
3378        }
3379    }
3380}
3381
3382// ─── UT: issue #14 — token store keyed by fingerprint, not nonce ────────────
3383#[cfg(test)]
3384mod token_fingerprint_store_tests {
3385    use super::*;
3386
3387    /// A token that was never attached fails verify with a `TokenNotFound`
3388    /// that carries the fingerprint — never the nonce. The error string can
3389    /// surface in HTTP error bodies, so this is the secret-hygiene contract.
3390    #[tokio::test]
3391    async fn verify_unknown_token_reports_fingerprint_not_nonce() {
3392        let engine = Engine::new(EngineCfg::default());
3393        // Signed by the engine's own signer (sig passes) but never inserted
3394        // into the store — verify must fail at step (4), the store lookup.
3395        let token = engine.signer().session(
3396            "ghost",
3397            Role::Operator,
3398            vec!["*".into()],
3399            Duration::from_secs(60),
3400        );
3401        let err = engine
3402            .verify_token(&token, Verb::ReadTaskState)
3403            .await
3404            .expect_err("token is not in the store");
3405        let msg = err.to_string();
3406        assert!(
3407            msg.contains(&token.fingerprint()),
3408            "error must carry the fingerprint: {msg}"
3409        );
3410        assert!(
3411            !msg.contains(&token.nonce),
3412            "error must not leak the nonce: {msg}"
3413        );
3414    }
3415
3416    /// attach → verify → heartbeat → detach all resolve the session /
3417    /// token record through fingerprint keys (mint/verify lifecycle
3418    /// regression guard for the issue #14 key migration).
3419    #[tokio::test]
3420    async fn attach_verify_heartbeat_detach_cycle_with_fp_keying() {
3421        let engine = Engine::new(EngineCfg::default());
3422        let token = engine
3423            .attach("op-1", Role::Operator, Duration::from_secs(60))
3424            .await
3425            .expect("attach");
3426        engine
3427            .verify_token(&token, Verb::ReadTaskState)
3428            .await
3429            .expect("verify consumes via fp key");
3430        engine
3431            .heartbeat(&token)
3432            .await
3433            .expect("heartbeat finds the session by fp");
3434        engine
3435            .detach(&token)
3436            .await
3437            .expect("detach finds the session by fp");
3438    }
3439}
3440
3441// ─── UT: `OperatorKind` "Runtime Global" tier — `Option` semantics ─────────
3442//
3443// Regression coverage for the "explicit Automate is indistinguishable from
3444// unspecified" defect: `OperatorSession.operator_kind` (and the
3445// `attach_with_ids` `kind` parameter it stores) is `Option<OperatorKind>`,
3446// so `Some(Automate)` is an explicit Runtime Global request that must
3447// outrank `bp_global`, while `None` must let `bp_global` decide. Exercises
3448// the real `resolve_operator_info` cascade path (not just
3449// `collapse_operator_kind` in isolation), attaching via `attach_with_ids`
3450// exactly as `TaskLaunchService::launch` does.
3451#[cfg(test)]
3452mod resolve_operator_info_runtime_global_tests {
3453    use super::*;
3454
3455    async fn attach_and_resolve(
3456        runtime_global: Option<OperatorKind>,
3457        bp_global: Option<OperatorKind>,
3458    ) -> OperatorInfo {
3459        let engine = Engine::new(EngineCfg::default());
3460        let token = engine
3461            .attach_with_ids(
3462                "ut-op",
3463                Role::Operator,
3464                Duration::from_secs(30),
3465                runtime_global,
3466                None,
3467                None,
3468                None,
3469                HashMap::new(),
3470                HashMap::new(),
3471                bp_global,
3472            )
3473            .await
3474            .expect("attach_with_ids ok");
3475        let session = engine
3476            .with_state("test.find_session", |s| {
3477                s.sessions
3478                    .values()
3479                    .find(|sess| sess.token_fp == token.fingerprint())
3480                    .cloned()
3481            })
3482            .await
3483            .expect("with_state ok")
3484            .expect("session present after attach_with_ids");
3485        engine.resolve_operator_info(&session, "agent-x").await
3486    }
3487
3488    #[tokio::test]
3489    async fn explicit_some_automate_outranks_bp_global_main_ai() {
3490        // Runtime Global explicitly requests Automate; bp_global is MainAi.
3491        // The explicit `Some(Automate)` must win — this is exactly the case
3492        // the old `== OperatorKind::default()` convention got wrong (it
3493        // could not tell "explicitly Automate" from "unspecified" and would
3494        // have let `bp_global` (MainAi) take over instead).
3495        let info =
3496            attach_and_resolve(Some(OperatorKind::Automate), Some(OperatorKind::MainAi)).await;
3497        assert_eq!(
3498            info.kind,
3499            OperatorKind::Automate,
3500            "explicit Some(Automate) runtime_global must outrank bp_global MainAi"
3501        );
3502    }
3503
3504    #[tokio::test]
3505    async fn none_lets_bp_global_main_ai_win() {
3506        // Runtime Global left unspecified (`None`); bp_global is MainAi.
3507        // With nothing more specific set, `bp_global` must decide.
3508        let info = attach_and_resolve(None, Some(OperatorKind::MainAi)).await;
3509        assert_eq!(
3510            info.kind,
3511            OperatorKind::MainAi,
3512            "None runtime_global must let bp_global MainAi win"
3513        );
3514    }
3515}
3516
3517/// issue #13 run_id propagation: `dispatch_attempt_with`'s `run_id` param
3518/// must land in `Ctx.meta.runtime["run_id"]` (the same slot pattern as the
3519/// pre-existing `worker_handle`), or be omitted entirely when `None`. Same
3520/// `CtxProbe` shape as `middleware::worker_binding`'s test module — an
3521/// inner `SpawnerAdapter` that snapshots the `Ctx` it was called with and
3522/// fails the spawn (only the ctx snapshot matters here).
3523#[cfg(test)]
3524mod dispatch_attempt_with_run_id_tests {
3525    use super::*;
3526    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3527    use crate::worker::Worker;
3528    use std::sync::Mutex as StdMutex;
3529
3530    struct CtxProbe {
3531        seen: Arc<StdMutex<Option<Ctx>>>,
3532    }
3533
3534    #[async_trait::async_trait]
3535    impl SpawnerAdapter for CtxProbe {
3536        async fn spawn(
3537            &self,
3538            _engine: &Engine,
3539            ctx: &Ctx,
3540            _task_id: StepId,
3541            _attempt: u32,
3542            _token: CapToken,
3543        ) -> Result<Box<dyn Worker>, SpawnError> {
3544            *self.seen.lock().unwrap() = Some(ctx.clone());
3545            Err(SpawnError::Internal("probe stop".into()))
3546        }
3547    }
3548
3549    async fn dispatch_with_probe(run_id: Option<&RunId>) -> Ctx {
3550        let engine = Engine::new(EngineCfg::default());
3551        let token = engine
3552            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3553            .await
3554            .expect("attach");
3555        let tid = engine
3556            .start_task(
3557                &token,
3558                TaskSpec {
3559                    agent: "probe".into(),
3560                    initial_directive: "hi".into(),
3561                    step_ctx: None,
3562                    check_policy: None,
3563                },
3564            )
3565            .await
3566            .expect("start_task");
3567        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3568        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3569        // The probe always errors the spawn (`SpawnError::Internal`); we
3570        // only care about the `Ctx` snapshot it captured, so the dispatch
3571        // outcome itself (`Err`) is discarded.
3572        let _ = engine
3573            .dispatch_attempt_with(&token, &tid, &spawner, run_id)
3574            .await;
3575        let captured = seen.lock().unwrap().clone();
3576        captured.expect("inner ctx captured")
3577    }
3578
3579    #[tokio::test]
3580    async fn run_id_lands_in_ctx_meta_runtime_when_some() {
3581        let run_id = RunId::new();
3582        let observed = dispatch_with_probe(Some(&run_id)).await;
3583        assert_eq!(
3584            observed.meta.runtime.get("run_id").and_then(|v| v.as_str()),
3585            Some(run_id.as_str()),
3586            "ctx.meta.runtime[\"run_id\"] must carry the run_id passed to dispatch_attempt_with"
3587        );
3588    }
3589
3590    #[tokio::test]
3591    async fn run_id_key_absent_when_none() {
3592        let observed = dispatch_with_probe(None).await;
3593        assert!(
3594            !observed.meta.runtime.contains_key("run_id"),
3595            "no run_id key must be injected when dispatch_attempt_with is called with None"
3596        );
3597    }
3598}
3599
3600/// GH #21 Phase 2: `TaskSpec.step_ctx` must land in
3601/// `Ctx.meta.runtime[STEP_CTX_KEY]` — re-read from the spec on EVERY
3602/// attempt (the prep closure re-reads `task.spec.step_ctx` every call, not
3603/// caching it once at `start_task`), so a retry (attempt 2) carries it
3604/// too. Same `CtxProbe` shape as `dispatch_attempt_with_run_id_tests`.
3605#[cfg(test)]
3606mod dispatch_attempt_with_step_ctx_tests {
3607    use super::*;
3608    use crate::worker::adapter::{SpawnError, SpawnerAdapter};
3609    use crate::worker::Worker;
3610    use std::sync::Mutex as StdMutex;
3611
3612    struct CtxProbe {
3613        seen: Arc<StdMutex<Option<Ctx>>>,
3614    }
3615
3616    #[async_trait::async_trait]
3617    impl SpawnerAdapter for CtxProbe {
3618        async fn spawn(
3619            &self,
3620            _engine: &Engine,
3621            ctx: &Ctx,
3622            _task_id: StepId,
3623            _attempt: u32,
3624            _token: CapToken,
3625        ) -> Result<Box<dyn Worker>, SpawnError> {
3626            *self.seen.lock().unwrap() = Some(ctx.clone());
3627            Err(SpawnError::Internal("probe stop".into()))
3628        }
3629    }
3630
3631    #[tokio::test]
3632    async fn step_ctx_lands_in_ctx_meta_runtime_on_attempt_1_and_2() {
3633        let engine = Engine::new(EngineCfg::default());
3634        let token = engine
3635            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3636            .await
3637            .expect("attach");
3638        let tid = engine
3639            .start_task(
3640                &token,
3641                TaskSpec {
3642                    agent: "probe".into(),
3643                    initial_directive: "hi".into(),
3644                    step_ctx: Some(serde_json::json!({ "work_dir": "/step" })),
3645                    check_policy: None,
3646                },
3647            )
3648            .await
3649            .expect("start_task");
3650        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3651        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3652
3653        // The probe always errors the spawn; only the ctx snapshot matters.
3654        let _ = engine
3655            .dispatch_attempt_with(&token, &tid, &spawner, None)
3656            .await;
3657        let first = seen
3658            .lock()
3659            .unwrap()
3660            .clone()
3661            .expect("attempt 1 ctx captured");
3662        assert_eq!(
3663            first.meta.runtime.get(STEP_CTX_KEY),
3664            Some(&serde_json::json!({ "work_dir": "/step" })),
3665            "attempt 1 must carry TaskSpec.step_ctx in ctx.meta.runtime[STEP_CTX_KEY]"
3666        );
3667
3668        let _ = engine
3669            .dispatch_attempt_with(&token, &tid, &spawner, None)
3670            .await;
3671        let second = seen
3672            .lock()
3673            .unwrap()
3674            .clone()
3675            .expect("attempt 2 ctx captured");
3676        assert_eq!(
3677            second.meta.runtime.get(STEP_CTX_KEY),
3678            Some(&serde_json::json!({ "work_dir": "/step" })),
3679            "attempt 2 (retry) must ALSO carry TaskSpec.step_ctx — prep re-reads the spec every attempt"
3680        );
3681    }
3682
3683    #[tokio::test]
3684    async fn step_ctx_key_absent_when_none() {
3685        let engine = Engine::new(EngineCfg::default());
3686        let token = engine
3687            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3688            .await
3689            .expect("attach");
3690        let tid = engine
3691            .start_task(
3692                &token,
3693                TaskSpec {
3694                    agent: "probe".into(),
3695                    initial_directive: "hi".into(),
3696                    step_ctx: None,
3697                    check_policy: None,
3698                },
3699            )
3700            .await
3701            .expect("start_task");
3702        let seen: Arc<StdMutex<Option<Ctx>>> = Arc::new(StdMutex::new(None));
3703        let spawner: Arc<dyn SpawnerAdapter> = Arc::new(CtxProbe { seen: seen.clone() });
3704        let _ = engine
3705            .dispatch_attempt_with(&token, &tid, &spawner, None)
3706            .await;
3707        let observed = seen.lock().unwrap().clone().expect("ctx captured");
3708        assert!(
3709            !observed.meta.runtime.contains_key(STEP_CTX_KEY),
3710            "no step_ctx key must be injected when TaskSpec.step_ctx is None"
3711        );
3712    }
3713}
3714
3715// ─── issue #18: `TaskSpec.initial_directive` `Value` pass-through ──────────
3716#[cfg(test)]
3717mod initial_directive_value_passthrough_tests {
3718    use super::*;
3719
3720    async fn seeded_engine(initial_directive: Value) -> (Engine, CapToken, StepId) {
3721        let engine = Engine::new(EngineCfg::default());
3722        let op_token = engine
3723            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3724            .await
3725            .expect("attach");
3726        let task_id = engine
3727            .start_task(
3728                &op_token,
3729                TaskSpec {
3730                    agent: "planner".to_string(),
3731                    initial_directive,
3732                    step_ctx: None,
3733                    check_policy: None,
3734                },
3735            )
3736            .await
3737            .expect("start_task");
3738        (engine, op_token, task_id)
3739    }
3740
3741    /// Mint + register a `Role::Worker` token the same way
3742    /// `dispatch_attempt_with` does — `fetch_prompt` is worker-verb-gated.
3743    async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3744        let worker_token = engine.signer().session(
3745            format!("worker-of-{task_id}"),
3746            Role::Worker,
3747            vec!["*".into()],
3748            Duration::from_secs(600),
3749        );
3750        let fp = worker_token.fingerprint();
3751        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3752        engine
3753            .with_state("test.mint_worker", move |s| {
3754                s.tokens.insert(fp, record);
3755            })
3756            .await
3757            .expect("mint worker token");
3758        worker_token
3759    }
3760
3761    /// `EngineDispatcher::dispatch` no longer stringifies the evaluated
3762    /// `Step.in` value before seeding `TaskSpec.initial_directive` — an
3763    /// Object seed must round-trip through `start_task` /
3764    /// `read_task_state` byte-for-byte as the same `Value::Object`, not a
3765    /// JSON-stringified `Value::String`.
3766    #[tokio::test]
3767    async fn object_seed_passes_through_task_spec_unchanged() {
3768        let seed = serde_json::json!({"key": "value"});
3769        let (engine, token, task_id) = seeded_engine(seed.clone()).await;
3770        let state = engine
3771            .read_task_state(&token, &task_id)
3772            .await
3773            .expect("read_task_state");
3774        assert_eq!(
3775            state.spec.initial_directive, seed,
3776            "TaskSpec.initial_directive must equal the raw Object seed, not a stringified copy"
3777        );
3778    }
3779
3780    /// `Engine::fetch_prompt` returns the `Value` end-to-end (issue #18):
3781    /// an Object seed stays a `Value::Object` and is not stringified in
3782    /// the engine layer. The Worker HTTP boundary
3783    /// (`fetch_worker_payload*`) is what performs the render down to a
3784    /// JSON literal `String` for `WorkerPayload.prompt`.
3785    #[tokio::test]
3786    async fn object_seed_passes_through_fetch_prompt_as_value() {
3787        let seed = serde_json::json!({"key": "value"});
3788        let (engine, _token, task_id) = seeded_engine(seed.clone()).await;
3789        let worker_token = mint_worker_token(&engine, &task_id).await;
3790        let prompt = engine
3791            .fetch_prompt(&worker_token, &task_id)
3792            .await
3793            .expect("fetch_prompt");
3794        assert_eq!(
3795            prompt, seed,
3796            "fetch_prompt must return the raw Object Value, not a stringified copy"
3797        );
3798    }
3799
3800    /// The Worker HTTP boundary is the render point: `fetch_worker_payload*`
3801    /// coerces the stored `Value` down to `WorkerPayload.prompt: String`
3802    /// (JSON-literal shape for non-strings). Verifies the boundary render
3803    /// stays intact for an Object seed.
3804    #[tokio::test]
3805    async fn object_seed_renders_as_json_literal_at_worker_payload_boundary() {
3806        let seed = serde_json::json!({"key": "value"});
3807        let (engine, _token, task_id) = seeded_engine(seed).await;
3808        let worker_token = mint_worker_token(&engine, &task_id).await;
3809        let payload = engine
3810            .fetch_worker_payload(&worker_token, &task_id)
3811            .await
3812            .expect("fetch_worker_payload");
3813        assert_eq!(
3814            payload.prompt, r#"{"key":"value"}"#,
3815            "WorkerPayload.prompt must be the JSON literal String render of the Value seed"
3816        );
3817    }
3818
3819    /// A `String` seed is unaffected — still passes through verbatim, both
3820    /// as the `TaskSpec.initial_directive` `Value` and as the Worker
3821    /// `fetch_prompt` return (issue #18 Invariant 2).
3822    #[tokio::test]
3823    async fn string_seed_passes_through_unchanged() {
3824        let (engine, token, task_id) = seeded_engine(serde_json::json!("do the thing")).await;
3825        let state = engine
3826            .read_task_state(&token, &task_id)
3827            .await
3828            .expect("read_task_state");
3829        assert_eq!(
3830            state.spec.initial_directive,
3831            serde_json::json!("do the thing")
3832        );
3833        let worker_token = mint_worker_token(&engine, &task_id).await;
3834        let prompt = engine
3835            .fetch_prompt(&worker_token, &task_id)
3836            .await
3837            .expect("fetch_prompt");
3838        assert_eq!(prompt, serde_json::json!("do the thing"));
3839    }
3840}
3841
3842/// GH #31: `fetch_worker_payload{,_trusted}`'s size-threshold branch
3843/// between inline (`WorkerPayload.system`) and by-reference
3844/// (`WorkerPayload.system_ref`) delivery, plus the `bake_worker_system_prompt`
3845/// `agent_render_sizes` bookkeeping that feeds `agent_last_rendered_size`.
3846#[cfg(test)]
3847mod system_ref_threshold_tests {
3848    use super::*;
3849
3850    async fn seeded_engine_with_cfg(cfg: EngineCfg) -> (Engine, CapToken, StepId) {
3851        let engine = Engine::new(cfg);
3852        let op_token = engine
3853            .attach("ut-op", Role::Operator, Duration::from_secs(30))
3854            .await
3855            .expect("attach");
3856        let task_id = engine
3857            .start_task(
3858                &op_token,
3859                TaskSpec {
3860                    agent: "planner".to_string(),
3861                    initial_directive: serde_json::json!("do the thing"),
3862                    step_ctx: None,
3863                    check_policy: None,
3864                },
3865            )
3866            .await
3867            .expect("start_task");
3868        (engine, op_token, task_id)
3869    }
3870
3871    /// Same worker-token-minting fixture as
3872    /// `initial_directive_value_passthrough_tests::mint_worker_token`
3873    /// (kept local to this module — the two `mod`s do not share private
3874    /// helpers across `cfg(test)` boundaries).
3875    async fn mint_worker_token(engine: &Engine, task_id: &StepId) -> CapToken {
3876        let worker_token = engine.signer().session(
3877            format!("worker-of-{task_id}"),
3878            Role::Worker,
3879            vec!["*".into()],
3880            Duration::from_secs(600),
3881        );
3882        let fp = worker_token.fingerprint();
3883        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
3884        engine
3885            .with_state("test.mint_worker", move |s| {
3886                s.tokens.insert(fp, record);
3887            })
3888            .await
3889            .expect("mint worker token");
3890        worker_token
3891    }
3892
3893    /// Under-threshold: `system` stays inline, `system_ref` stays `None`.
3894    #[tokio::test]
3895    async fn under_threshold_stays_inline() {
3896        let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3897        let worker_token = mint_worker_token(&engine, &task_id).await;
3898        let rendered = "a short system prompt".to_string();
3899        engine
3900            .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3901            .await
3902            .expect("bake");
3903        let payload = engine
3904            .fetch_worker_payload(&worker_token, &task_id)
3905            .await
3906            .expect("fetch_worker_payload");
3907        assert_eq!(payload.system, Some(rendered));
3908        assert!(payload.system_ref.is_none());
3909    }
3910
3911    /// Over-threshold: `system` is cleared and `system_ref` is populated
3912    /// with a `sha256` matching the known input string. Exercises
3913    /// `fetch_worker_payload_trusted` (the `_trusted` sibling must be
3914    /// behaviorally identical to `fetch_worker_payload`).
3915    #[tokio::test]
3916    async fn over_threshold_switches_to_system_ref_with_matching_sha256() {
3917        let mut cfg = EngineCfg::default();
3918        cfg.system_ref.threshold_bytes = 16;
3919        cfg.system_ref.mode = crate::types::SystemRefMode::File;
3920        cfg.system_ref.store_dir =
3921            std::env::temp_dir().join(format!("mse-system-ref-test-{}", crate::types::now_unix()));
3922        let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3923        let rendered =
3924            "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3925        engine
3926            .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
3927            .await
3928            .expect("bake");
3929        let payload = engine
3930            .fetch_worker_payload_trusted(&task_id)
3931            .await
3932            .expect("fetch_worker_payload_trusted");
3933        assert!(
3934            payload.system.is_none(),
3935            "over-threshold response must not also inline `system`"
3936        );
3937        let system_ref = payload
3938            .system_ref
3939            .expect("over-threshold response must populate system_ref");
3940        assert_eq!(system_ref.size_bytes, rendered.len() as u64);
3941        assert_eq!(system_ref.mode, crate::types::SystemRefMode::File);
3942        use sha2::Digest;
3943        let expected_sha256 = hex::encode(sha2::Sha256::digest(rendered.as_bytes()));
3944        assert_eq!(system_ref.sha256, expected_sha256);
3945        assert!(system_ref.uri.starts_with("file://"));
3946        let written = tokio::fs::read_to_string(system_ref.uri.trim_start_matches("file://"))
3947            .await
3948            .expect("File mode must have written the referenced path");
3949        assert_eq!(written, rendered);
3950    }
3951
3952    /// `Http` mode never writes a file — `system_ref.uri` is the bare path
3953    /// the engine can construct on its own, scheme/host-free.
3954    #[tokio::test]
3955    async fn over_threshold_http_mode_constructs_path_only_uri() {
3956        let mut cfg = EngineCfg::default();
3957        cfg.system_ref.threshold_bytes = 16;
3958        cfg.system_ref.mode = crate::types::SystemRefMode::Http;
3959        let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
3960        let worker_token = mint_worker_token(&engine, &task_id).await;
3961        let rendered =
3962            "this system prompt is deliberately longer than the 16 byte threshold".to_string();
3963        engine
3964            .bake_worker_system_prompt(&task_id, 1, Some(rendered))
3965            .await
3966            .expect("bake");
3967        let payload = engine
3968            .fetch_worker_payload(&worker_token, &task_id)
3969            .await
3970            .expect("fetch_worker_payload");
3971        let system_ref = payload.system_ref.expect("system_ref must be populated");
3972        assert_eq!(system_ref.mode, crate::types::SystemRefMode::Http);
3973        assert_eq!(
3974            system_ref.uri,
3975            format!("/v1/worker/prompt/system?task_id={task_id}&attempt=1")
3976        );
3977    }
3978
3979    /// `bake_worker_system_prompt` records the render size keyed by agent
3980    /// name (last-write-wins), readable via `agent_last_rendered_size`.
3981    #[tokio::test]
3982    async fn bake_records_agent_render_size_last_write_wins() {
3983        let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
3984        assert_eq!(engine.agent_last_rendered_size("planner").await, None);
3985        engine
3986            .bake_worker_system_prompt(&task_id, 1, Some("a".repeat(10)))
3987            .await
3988            .expect("bake 1");
3989        assert_eq!(engine.agent_last_rendered_size("planner").await, Some(10));
3990        engine
3991            .bake_worker_system_prompt(&task_id, 2, Some("b".repeat(20)))
3992            .await
3993            .expect("bake 2");
3994        assert_eq!(
3995            engine.agent_last_rendered_size("planner").await,
3996            Some(20),
3997            "most-recently-observed size wins, not the largest"
3998        );
3999    }
4000
4001    /// GH #83: `materialize_system_file` writes the baked system prompt
4002    /// unconditionally — a system well UNDER `threshold_bytes` still
4003    /// lands on disk, because a `{system_file}` template reference needs
4004    /// a real path regardless of size.
4005    #[tokio::test]
4006    async fn materialize_system_file_writes_under_threshold_system() {
4007        let mut cfg = EngineCfg::default();
4008        cfg.system_ref.store_dir =
4009            std::env::temp_dir().join(format!("mse-system-file-test-{}", crate::types::now_unix()));
4010        assert!(cfg.system_ref.threshold_bytes > 64, "fixture premise");
4011        let (engine, _op_token, task_id) = seeded_engine_with_cfg(cfg).await;
4012        let rendered = "a short system prompt".to_string();
4013        engine
4014            .bake_worker_system_prompt(&task_id, 1, Some(rendered.clone()))
4015            .await
4016            .expect("bake");
4017        let path = engine
4018            .materialize_system_file(&task_id, 1)
4019            .await
4020            .expect("materialize_system_file")
4021            .expect("baked system must yield a path");
4022        let written = tokio::fs::read_to_string(&path)
4023            .await
4024            .expect("materialized path must exist");
4025        assert_eq!(written, rendered);
4026    }
4027
4028    /// GH #83: no baked system → `Ok(None)` (the Subprocess spawn path
4029    /// turns this into a fail-loud `SpawnError` when `{system_file}` is
4030    /// actually referenced).
4031    #[tokio::test]
4032    async fn materialize_system_file_none_when_nothing_baked() {
4033        let (engine, _op_token, task_id) = seeded_engine_with_cfg(EngineCfg::default()).await;
4034        let path = engine
4035            .materialize_system_file(&task_id, 1)
4036            .await
4037            .expect("materialize_system_file");
4038        assert!(path.is_none());
4039    }
4040}
4041
4042/// subtask-4 / ST2 rework: `submit_output` / `submit_worker_result_trusted`'s
4043/// submit-time projection sink (`Engine::materialize_final_submission`) —
4044/// the Data-plane `OutputStore` dual-write plus the
4045/// `FileProjectionAdapter`-backed file materialize, both fail-open. See
4046/// the subtask-4 Tests this module covers inline on each test.
4047#[cfg(test)]
4048mod submit_time_projection_sink_tests {
4049    use super::*;
4050    use crate::core::agent_context::AgentContextView;
4051    use crate::store::output::{ContentRef, InMemoryOutputStore, OutputEvent};
4052
4053    /// Starts a task under `agent`, returning `(engine, op_token, task_id,
4054    /// worker_token)` — same helper shape as the sibling test modules
4055    /// above (`initial_directive_value_passthrough_tests::seeded_engine` /
4056    /// `mint_worker_token`), duplicated locally per this file's
4057    /// established per-module convention.
4058    async fn seeded_task(agent: &str) -> (Engine, CapToken, StepId, CapToken) {
4059        let engine = Engine::new(EngineCfg::default());
4060        let op_token = engine
4061            .attach("ut-op", Role::Operator, Duration::from_secs(30))
4062            .await
4063            .expect("attach");
4064        let task_id = engine
4065            .start_task(
4066                &op_token,
4067                TaskSpec {
4068                    agent: agent.to_string(),
4069                    initial_directive: Value::String("go".into()),
4070                    step_ctx: None,
4071                    check_policy: None,
4072                },
4073            )
4074            .await
4075            .expect("start_task");
4076        let worker_token = engine.signer().session(
4077            format!("worker-of-{task_id}"),
4078            Role::Worker,
4079            vec!["*".into()],
4080            Duration::from_secs(600),
4081        );
4082        let fp = worker_token.fingerprint();
4083        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
4084        engine
4085            .with_state("test.mint_worker", move |s| {
4086                s.tokens.insert(fp, record);
4087            })
4088            .await
4089            .expect("mint worker token");
4090        (engine, op_token, task_id, worker_token)
4091    }
4092
4093    /// Sibling of [`seeded_task`] that lets a caller pin the engine's
4094    /// `EngineCfg.check_policy` before the engine is constructed — used
4095    /// by the `check_policy_*` regression tests below to exercise the
4096    /// three [`crate::core::config::CheckPolicy`] modes without touching
4097    /// the shared `seeded_task` helper (which every unrelated sink test
4098    /// depends on).
4099    async fn seeded_task_with_policy(
4100        agent: &str,
4101        policy: crate::core::config::CheckPolicy,
4102    ) -> (Engine, CapToken, StepId, CapToken) {
4103        let cfg = EngineCfg {
4104            check_policy: policy,
4105            ..EngineCfg::default()
4106        };
4107        let engine = Engine::new(cfg);
4108        let op_token = engine
4109            .attach("ut-op", Role::Operator, Duration::from_secs(30))
4110            .await
4111            .expect("attach");
4112        let task_id = engine
4113            .start_task(
4114                &op_token,
4115                TaskSpec {
4116                    agent: agent.to_string(),
4117                    initial_directive: Value::String("go".into()),
4118                    step_ctx: None,
4119                    check_policy: None,
4120                },
4121            )
4122            .await
4123            .expect("start_task");
4124        let worker_token = engine.signer().session(
4125            format!("worker-of-{task_id}"),
4126            Role::Worker,
4127            vec!["*".into()],
4128            Duration::from_secs(600),
4129        );
4130        let fp = worker_token.fingerprint();
4131        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
4132        engine
4133            .with_state("test.mint_worker", move |s| {
4134                s.tokens.insert(fp, record);
4135            })
4136            .await
4137            .expect("mint worker token");
4138        (engine, op_token, task_id, worker_token)
4139    }
4140
4141    /// Seeds `EngineState.agent_ctx[(task_id, attempt)].view` directly —
4142    /// the same snapshot `AgentContextMiddleware` writes at spawn time
4143    /// (see its module doc), stood up here without the full spawner
4144    /// stack so these tests can exercise `submit_output` in isolation.
4145    async fn seed_agent_context(engine: &Engine, task_id: &StepId, attempt: u32, work_dir: &str) {
4146        let task_id = task_id.clone();
4147        let work_dir = work_dir.to_string();
4148        engine
4149            .with_state("test.seed_agent_context", move |s| {
4150                s.agent_ctx.insert(
4151                    (task_id, attempt),
4152                    crate::core::state::AgentCtxEntry {
4153                        view: AgentContextView {
4154                            work_dir: Some(work_dir),
4155                            ..Default::default()
4156                        },
4157                        policy: Default::default(),
4158                    },
4159                );
4160            })
4161            .await
4162            .expect("seed agent_ctx");
4163    }
4164
4165    /// GH #27 (follow-up to #23): seeds `EngineState.agent_ctx` with an
4166    /// arbitrary `work_dir` / `project_root` pair (either may be `None`),
4167    /// unlike [`seed_agent_context`] (which only ever sets `work_dir`) —
4168    /// lets these tests exercise `ProjectionPlacement::resolve_root`'s
4169    /// fallback in both directions.
4170    async fn seed_agent_context_roots(
4171        engine: &Engine,
4172        task_id: &StepId,
4173        attempt: u32,
4174        work_dir: Option<&str>,
4175        project_root: Option<&str>,
4176    ) {
4177        let task_id = task_id.clone();
4178        let work_dir = work_dir.map(str::to_string);
4179        let project_root = project_root.map(str::to_string);
4180        engine
4181            .with_state("test.seed_agent_context_roots", move |s| {
4182                s.agent_ctx.insert(
4183                    (task_id, attempt),
4184                    crate::core::state::AgentCtxEntry {
4185                        view: AgentContextView {
4186                            work_dir,
4187                            project_root,
4188                            ..Default::default()
4189                        },
4190                        policy: Default::default(),
4191                    },
4192                );
4193            })
4194            .await
4195            .expect("seed agent_ctx");
4196    }
4197
4198    /// GH #27 (follow-up to #23): seeds `EngineState.projection_placements`
4199    /// directly — the same snapshot `EngineDispatcher::dispatch` stashes
4200    /// at dispatch time (mirroring [`seed_step_naming`]'s contract) — so
4201    /// these tests can exercise a declared `ProjectionPlacement` without
4202    /// driving a real `Compiler::compile`.
4203    async fn seed_projection_placement(
4204        engine: &Engine,
4205        task_id: &StepId,
4206        placement: crate::core::projection_placement::ProjectionPlacement,
4207    ) {
4208        let task_id = task_id.clone();
4209        let placement = Arc::new(placement);
4210        engine
4211            .with_state("test.seed_projection_placement", move |s| {
4212                s.projection_placements.insert(task_id, placement);
4213            })
4214            .await
4215            .expect("seed projection_placements");
4216    }
4217
4218    /// GH #23 subtask-2: builds a fixture
4219    /// [`crate::core::step_naming::StepNaming`] table declaring `producer`
4220    /// → `canonical` (`AgentMeta.projection_name`), then seeds it into
4221    /// `EngineState.step_namings` for `task_id` — the same snapshot
4222    /// `EngineDispatcher::dispatch` stashes at dispatch time
4223    /// (`blueprint.rs`'s "construct once, read many" contract), stood up
4224    /// here without the full Blueprint-compile path so these tests can
4225    /// exercise the canonical-sink resolution in isolation.
4226    async fn seed_step_naming(engine: &Engine, task_id: &StepId, producer: &str, canonical: &str) {
4227        use crate::blueprint::{
4228            current_schema_version, AgentDef, AgentKind, AgentMeta, Blueprint, BlueprintMetadata,
4229            CompilerHints, CompilerStrategy,
4230        };
4231        use crate::core::step_naming::StepNaming;
4232        use mlua_flow_ir::{Expr, Node};
4233
4234        let flow = Node::Step {
4235            ref_: producer.to_string(),
4236            in_: Expr::Path {
4237                at: "$.in".parse().expect("literal test path: $.in"),
4238            },
4239            out: Expr::Path {
4240                at: format!("$.{producer}_out")
4241                    .parse()
4242                    .expect("literal test path"),
4243            },
4244        };
4245        let bp = Blueprint {
4246            schema_version: current_schema_version(),
4247            id: "sink-canonical-ut".into(),
4248            flow,
4249            agents: vec![AgentDef {
4250                name: producer.to_string(),
4251                kind: AgentKind::RustFn,
4252                spec: serde_json::json!({ "fn_id": producer }),
4253                profile: None,
4254                meta: Some(AgentMeta {
4255                    projection_name: Some(canonical.to_string()),
4256                    ..Default::default()
4257                }),
4258                runner: None,
4259                runner_ref: None,
4260                verdict: None,
4261            }],
4262            operators: vec![],
4263            metas: vec![],
4264            hints: CompilerHints::default(),
4265            strategy: CompilerStrategy::default(),
4266            metadata: BlueprintMetadata::default(),
4267            spawner_hints: Default::default(),
4268            default_agent_kind: AgentKind::Operator,
4269            default_operator_kind: None,
4270            default_init_ctx: None,
4271            default_agent_ctx: None,
4272            default_context_policy: None,
4273            projection_placement: None,
4274            audits: vec![],
4275            degradation_policy: None,
4276            runners: vec![],
4277            default_runner: None,
4278            subprocesses: vec![],
4279            check_policy: None,
4280            blueprint_ref_includes: Vec::new(),
4281        };
4282        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
4283        assert!(warnings.is_empty(), "single-step fixture has no collisions");
4284        let naming = Arc::new(naming);
4285        let task_id = task_id.clone();
4286        engine
4287            .with_state("test.seed_step_naming", move |s| {
4288                s.step_namings.insert(task_id, naming);
4289            })
4290            .await
4291            .expect("seed step_namings");
4292    }
4293
4294    fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
4295        crate::worker::output::OutputEvent::Final {
4296            content: crate::worker::output::ContentRef::Inline { value },
4297            ok,
4298        }
4299    }
4300
4301    /// Subtask 4 Test #2: `submit_output`'s `Final` writes
4302    /// `<root>/workspace/tasks/<task_id>/ctx/<agent>.md`, content matching
4303    /// the submitted value.
4304    #[tokio::test]
4305    async fn submit_output_final_materializes_file_when_work_dir_resolved() {
4306        let dir = tempfile::TempDir::new().unwrap();
4307        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4308        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4309
4310        engine
4311            .submit_output(
4312                &worker_token,
4313                &task_id,
4314                1,
4315                final_event(serde_json::json!({"plan": "do it"}), true),
4316            )
4317            .await
4318            .expect("submit_output");
4319
4320        let expected_file = dir
4321            .path()
4322            .join("workspace/tasks")
4323            .join(task_id.as_str())
4324            .join("ctx/planner.md");
4325        assert!(
4326            expected_file.exists(),
4327            "materialized submission file missing at {expected_file:?}"
4328        );
4329        let body = std::fs::read_to_string(expected_file).unwrap();
4330        assert!(body.contains(r#""plan": "do it""#), "body: {body}");
4331    }
4332
4333    /// Subtask 4 Test #3: `work_dir` unresolved (no `agent_ctx`
4334    /// snapshot for this `(task_id, attempt)`) — submit still succeeds,
4335    /// fail-open, no file.
4336    #[tokio::test]
4337    async fn submit_output_final_skips_file_when_root_unresolved() {
4338        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4339        // No seed_agent_context call — root is unresolved.
4340
4341        let result = engine
4342            .submit_output(
4343                &worker_token,
4344                &task_id,
4345                1,
4346                final_event(serde_json::json!("hi"), true),
4347            )
4348            .await;
4349        assert!(
4350            result.is_ok(),
4351            "submit must succeed even with no resolvable root (fail-open, Invariant 1)"
4352        );
4353    }
4354
4355    /// Regression for the check_policy cascade: the default
4356    /// [`crate::core::config::CheckPolicy::Warn`] preserves the
4357    /// pre-`CheckPolicy` fail-open semantics — a submit whose root is
4358    /// unresolved still succeeds. Byte-compat with
4359    /// `submit_output_final_skips_file_when_root_unresolved`; this test
4360    /// pins the mode explicitly so a future default change to
4361    /// `Strict` (silent breakage) is caught here.
4362    #[tokio::test]
4363    async fn submit_output_final_check_policy_warn_preserves_fail_open() {
4364        let (engine, _op, task_id, worker_token) =
4365            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4366
4367        let result = engine
4368            .submit_output(
4369                &worker_token,
4370                &task_id,
4371                1,
4372                final_event(serde_json::json!("hi"), true),
4373            )
4374            .await;
4375        assert!(
4376            result.is_ok(),
4377            "Warn mode preserves fail-open: submit must succeed when root unresolved"
4378        );
4379    }
4380
4381    /// Regression for the check_policy cascade:
4382    /// [`crate::core::config::CheckPolicy::Strict`] surfaces the "no
4383    /// work_dir/project_root resolved" fail-open condition as an
4384    /// [`EngineError::CheckPolicyStrict`], letting a caller who has
4385    /// opted in fail fast instead of proceeding with a partially-
4386    /// realized submission. The error's `context` identifies the call
4387    /// site (`"file materialize"`), and `message` preserves the
4388    /// pre-`CheckPolicy` warn literal verbatim (log-parse compat).
4389    #[tokio::test]
4390    async fn submit_output_final_check_policy_strict_surfaces_error_when_root_unresolved() {
4391        let (engine, _op, task_id, worker_token) =
4392            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4393
4394        let err = engine
4395            .submit_output(
4396                &worker_token,
4397                &task_id,
4398                1,
4399                final_event(serde_json::json!("hi"), true),
4400            )
4401            .await
4402            .expect_err("Strict mode must return an error when root unresolved");
4403        match err {
4404            EngineError::CheckPolicyStrict { context, message } => {
4405                assert!(
4406                    context.contains("file materialize"),
4407                    "context must identify the call site: {context}"
4408                );
4409                assert!(
4410                    message.contains("no work_dir/project_root resolved"),
4411                    "message must preserve the warn-log literal for log-parse compat: {message}"
4412                );
4413            }
4414            other => panic!(
4415                "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4416            ),
4417        }
4418    }
4419
4420    /// Regression for the check_policy cascade:
4421    /// [`crate::core::config::CheckPolicy::Silent`] returns `Ok(())` (
4422    /// like `Warn`) without surfacing an error. The log-suppression side
4423    /// of `Silent` (no `tracing::warn!`) is enforced at the call site
4424    /// via the `if !matches!(policy, Silent) { warn!(...) }` guard —
4425    /// verifying tracing output shape here would couple the test to a
4426    /// subscriber setup, so the assertion is limited to the error-
4427    /// return semantics (matches the helper unit tests in
4428    /// `check_policy_helper_tests`).
4429    #[tokio::test]
4430    async fn submit_output_final_check_policy_silent_returns_ok_when_root_unresolved() {
4431        let (engine, _op, task_id, worker_token) =
4432            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Silent).await;
4433
4434        let result = engine
4435            .submit_output(
4436                &worker_token,
4437                &task_id,
4438                1,
4439                final_event(serde_json::json!("hi"), true),
4440            )
4441            .await;
4442        assert!(
4443            result.is_ok(),
4444            "Silent mode returns Ok(()) at the error surface: submit must succeed"
4445        );
4446    }
4447
4448    /// Subtask 4 Test #4 (file half): re-submitting under the same
4449    /// `(task_id, agent)` overwrites the materialized file with the
4450    /// latest value.
4451    #[tokio::test]
4452    async fn resubmit_overwrites_materialized_file_with_latest() {
4453        let dir = tempfile::TempDir::new().unwrap();
4454        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4455        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4456
4457        engine
4458            .submit_output(
4459                &worker_token,
4460                &task_id,
4461                1,
4462                final_event(serde_json::json!("first"), true),
4463            )
4464            .await
4465            .expect("first submit");
4466        engine
4467            .submit_output(
4468                &worker_token,
4469                &task_id,
4470                1,
4471                final_event(serde_json::json!("second"), true),
4472            )
4473            .await
4474            .expect("second submit");
4475
4476        let expected_file = dir
4477            .path()
4478            .join("workspace/tasks")
4479            .join(task_id.as_str())
4480            .join("ctx/planner.md");
4481        let body = std::fs::read_to_string(expected_file).unwrap();
4482        assert!(body.contains("second"), "body must reflect latest: {body}");
4483        assert!(
4484            !body.contains("first"),
4485            "body must not carry the stale value: {body}"
4486        );
4487    }
4488
4489    /// GH #27 (follow-up to #23): the byte-compat default
4490    /// `ProjectionPlacement` (`root_preference = WorkDir`) falls back to
4491    /// `project_root` when `work_dir` is absent — the same fallback
4492    /// [`crate::core::projection_placement::ProjectionPlacement::resolve_root`]
4493    /// now performs for every one of the "3 path" call sites, this one
4494    /// exercised at the submit-sink layer.
4495    #[tokio::test]
4496    async fn submit_output_final_falls_back_to_project_root_when_work_dir_absent() {
4497        let dir = tempfile::TempDir::new().unwrap();
4498        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4499        seed_agent_context_roots(
4500            &engine,
4501            &task_id,
4502            1,
4503            None,
4504            Some(&dir.path().to_string_lossy()),
4505        )
4506        .await;
4507
4508        engine
4509            .submit_output(
4510                &worker_token,
4511                &task_id,
4512                1,
4513                final_event(serde_json::json!({"plan": "via project_root"}), true),
4514            )
4515            .await
4516            .expect("submit_output");
4517
4518        let expected_file = dir
4519            .path()
4520            .join("workspace/tasks")
4521            .join(task_id.as_str())
4522            .join("ctx/planner.md");
4523        assert!(
4524            expected_file.exists(),
4525            "materialized submission file missing at {expected_file:?} \
4526             (work_dir absent must fall back to project_root)"
4527        );
4528    }
4529
4530    /// GH #27 (follow-up to #23): a declared `ProjectionPlacement`
4531    /// (`root_preference = ProjectRoot`, custom `dir_template`) changes
4532    /// BOTH which root is preferred (project_root wins even though
4533    /// work_dir is also present) AND the target directory layout — proof
4534    /// the submit sink consults the snapshotted resolver rather than a
4535    /// hardcoded layout.
4536    #[tokio::test]
4537    async fn submit_output_final_uses_declared_projection_placement() {
4538        let work_dir = tempfile::TempDir::new().unwrap();
4539        let project_root = tempfile::TempDir::new().unwrap();
4540        let (engine, _op, task_id, worker_token) = seeded_task("planner").await;
4541        seed_agent_context_roots(
4542            &engine,
4543            &task_id,
4544            1,
4545            Some(&work_dir.path().to_string_lossy()),
4546            Some(&project_root.path().to_string_lossy()),
4547        )
4548        .await;
4549        seed_projection_placement(
4550            &engine,
4551            &task_id,
4552            crate::core::projection_placement::ProjectionPlacement {
4553                root_preference: crate::core::projection_placement::RootPreference::ProjectRoot,
4554                dir_template: "custom/{task_id}/out".to_string(),
4555            },
4556        )
4557        .await;
4558
4559        engine
4560            .submit_output(
4561                &worker_token,
4562                &task_id,
4563                1,
4564                final_event(serde_json::json!({"plan": "via custom placement"}), true),
4565            )
4566            .await
4567            .expect("submit_output");
4568
4569        let expected_file = project_root
4570            .path()
4571            .join("custom")
4572            .join(task_id.as_str())
4573            .join("out/planner.md");
4574        assert!(
4575            expected_file.exists(),
4576            "materialized submission file missing at custom placement target {expected_file:?}"
4577        );
4578        let unexpected_file = work_dir
4579            .path()
4580            .join("workspace/tasks")
4581            .join(task_id.as_str())
4582            .join("ctx/planner.md");
4583        assert!(
4584            !unexpected_file.exists(),
4585            "declared root_preference=ProjectRoot must not fall back to work_dir: {unexpected_file:?}"
4586        );
4587    }
4588
4589    /// Subtask 4 Invariant 3 / crux requirement #3: when
4590    /// [`Engine::set_output_store`] wires a Data-plane [`crate::store::output::OutputStore`],
4591    /// `submit_output`'s `Final` dual-writes into it under
4592    /// `producer_agent = TaskState.spec.agent` — the store becomes
4593    /// queryable via `get_latest_by_name`, independent of whether a root
4594    /// resolved for the file half.
4595    #[tokio::test]
4596    async fn submit_output_final_dual_writes_into_configured_output_store() {
4597        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4598        let data_store: Arc<dyn crate::store::output::OutputStore> =
4599            Arc::new(InMemoryOutputStore::new());
4600        engine.set_output_store(data_store.clone());
4601
4602        engine
4603            .submit_output(
4604                &worker_token,
4605                &task_id,
4606                1,
4607                final_event(serde_json::json!({"verdict": "pass"}), true),
4608            )
4609            .await
4610            .expect("submit_output");
4611
4612        let record = data_store
4613            .get_latest_by_name("reviewer")
4614            .await
4615            .expect("dual-written record");
4616        match record.event {
4617            OutputEvent::Final { content, ok } => {
4618                assert!(ok);
4619                match content {
4620                    ContentRef::Inline { value } => {
4621                        assert_eq!(value, serde_json::json!({"verdict": "pass"}));
4622                    }
4623                    other => panic!("expected Inline content, got {other:?}"),
4624                }
4625            }
4626            other => panic!("expected Final event, got {other:?}"),
4627        }
4628    }
4629
4630    /// GH #34 subtask-3 gap fix: an `Artifact` event submitted via
4631    /// `submit_output` dual-writes into a wired Data-plane `OutputStore`
4632    /// under its OWN `name`, verbatim — mirrors
4633    /// `submit_output_final_dual_writes_into_configured_output_store`
4634    /// above, but for the `Artifact` variant.
4635    #[tokio::test]
4636    async fn submit_output_artifact_dual_writes_into_configured_output_store() {
4637        let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4638        let data_store: Arc<dyn crate::store::output::OutputStore> =
4639            Arc::new(InMemoryOutputStore::new());
4640        engine.set_output_store(data_store.clone());
4641
4642        engine
4643            .submit_output(
4644                &worker_token,
4645                &task_id,
4646                1,
4647                OutputEvent::Artifact {
4648                    name: "audit:echo".to_string(),
4649                    content: ContentRef::Inline {
4650                        value: serde_json::json!({"finding": "clean"}),
4651                    },
4652                },
4653            )
4654            .await
4655            .expect("submit_output");
4656
4657        let record = data_store
4658            .get_latest_by_name("audit:echo")
4659            .await
4660            .expect("dual-written artifact record");
4661        match record.event {
4662            OutputEvent::Artifact { name, content } => {
4663                assert_eq!(name, "audit:echo");
4664                match content {
4665                    ContentRef::Inline { value } => {
4666                        assert_eq!(value, serde_json::json!({"finding": "clean"}));
4667                    }
4668                    other => panic!("expected Inline content, got {other:?}"),
4669                }
4670            }
4671            other => panic!("expected Artifact event, got {other:?}"),
4672        }
4673        // The `Artifact` dual-write must never collide with / overwrite
4674        // the producing step's own `Final` name — `submit_output` never
4675        // materialized a `Final` here, so `"echo"` must stay unresolved.
4676        assert!(
4677            data_store.get_latest_by_name("echo").await.is_err(),
4678            "artifact write must not fabricate a record under the raw producer_agent name"
4679        );
4680    }
4681
4682    /// Invariant 1 (fail-open) for `Artifact`, mirroring
4683    /// `submit_output_final_skips_file_when_root_unresolved`'s Final-side
4684    /// coverage: no `OutputStore` wired at all — submit still succeeds.
4685    #[tokio::test]
4686    async fn submit_output_artifact_is_fail_open_when_no_output_store_configured() {
4687        let (engine, _op, task_id, worker_token) = seeded_task("echo").await;
4688
4689        let result = engine
4690            .submit_output(
4691                &worker_token,
4692                &task_id,
4693                1,
4694                OutputEvent::Artifact {
4695                    name: "audit:echo".to_string(),
4696                    content: ContentRef::Inline {
4697                        value: serde_json::json!("finding"),
4698                    },
4699                },
4700            )
4701            .await;
4702        assert!(
4703            result.is_ok(),
4704            "submit must succeed even with no OutputStore wired (fail-open, Invariant 1)"
4705        );
4706    }
4707
4708    /// `submit_worker_result_trusted` (the `/v1/worker/submit` short-handle
4709    /// path) triggers the exact same sink as `submit_output` — parity
4710    /// across both worker-submit entry points.
4711    #[tokio::test]
4712    async fn submit_worker_result_trusted_also_triggers_projection_sink() {
4713        let dir = tempfile::TempDir::new().unwrap();
4714        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4715        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4716        let data_store: Arc<dyn crate::store::output::OutputStore> =
4717            Arc::new(InMemoryOutputStore::new());
4718        engine.set_output_store(data_store.clone());
4719
4720        engine
4721            .submit_worker_result_trusted(
4722                &task_id,
4723                1,
4724                serde_json::json!("trusted-value"),
4725                SubmitOutcome::Pass,
4726            )
4727            .await
4728            .expect("submit_worker_result_trusted");
4729
4730        let expected_file = dir
4731            .path()
4732            .join("workspace/tasks")
4733            .join(task_id.as_str())
4734            .join("ctx/planner.md");
4735        assert!(expected_file.exists());
4736        let record = data_store
4737            .get_latest_by_name("planner")
4738            .await
4739            .expect("dual-written record");
4740        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4741    }
4742
4743    /// GH #23 subtask-2 (canonical sink): a declared `projection_name`
4744    /// (`AgentMeta.projection_name`, surfaced via `StepNaming`) redirects
4745    /// `submit_output`'s Final canonical sink — both the Data-plane
4746    /// dual-write name and the materialized file stem resolve to the
4747    /// canonical name, not the raw `producer_agent`.
4748    #[tokio::test]
4749    async fn submit_output_final_uses_canonical_name_when_step_naming_declares_one() {
4750        let dir = tempfile::TempDir::new().unwrap();
4751        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4752        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4753        seed_step_naming(&engine, &task_id, "reviewer", "verdict-final").await;
4754        let data_store: Arc<dyn crate::store::output::OutputStore> =
4755            Arc::new(InMemoryOutputStore::new());
4756        engine.set_output_store(data_store.clone());
4757
4758        engine
4759            .submit_output(
4760                &worker_token,
4761                &task_id,
4762                1,
4763                final_event(serde_json::json!({"verdict": "pass"}), true),
4764            )
4765            .await
4766            .expect("submit_output");
4767
4768        let record = data_store
4769            .get_latest_by_name("verdict-final")
4770            .await
4771            .expect("dual-written record under canonical name");
4772        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4773        assert!(
4774            data_store.get_latest_by_name("reviewer").await.is_err(),
4775            "raw producer_agent name must not be written once canonical resolves"
4776        );
4777
4778        let expected_file = dir
4779            .path()
4780            .join("workspace/tasks")
4781            .join(task_id.as_str())
4782            .join("ctx/verdict-final.md");
4783        assert!(
4784            expected_file.exists(),
4785            "materialized file stem must be canonical at {expected_file:?}"
4786        );
4787    }
4788
4789    /// GH #23 subtask-2: no `StepNaming` table snapshotted for this
4790    /// `task_id` (the pre-GH-#23 / no-`with_step_naming` path) is a
4791    /// defensive fail-open — the canonical sink falls back to the raw
4792    /// `producer_agent`, byte-identical to
4793    /// `submit_output_final_dual_writes_into_configured_output_store`
4794    /// above (which never calls `seed_step_naming`).
4795    #[tokio::test]
4796    async fn submit_output_final_falls_back_to_producer_agent_when_no_step_naming_table() {
4797        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4798        let data_store: Arc<dyn crate::store::output::OutputStore> =
4799            Arc::new(InMemoryOutputStore::new());
4800        engine.set_output_store(data_store.clone());
4801
4802        engine
4803            .submit_output(
4804                &worker_token,
4805                &task_id,
4806                1,
4807                final_event(serde_json::json!({"verdict": "pass"}), true),
4808            )
4809            .await
4810            .expect("submit_output");
4811
4812        let record = data_store
4813            .get_latest_by_name("reviewer")
4814            .await
4815            .expect("fail-open dual-write under raw producer_agent name");
4816        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4817    }
4818
4819    /// GH #23 subtask-2 (Layer 2): `OutputStore::get_latest_by_name_in_run`
4820    /// resolves the value `submit_output` dual-wrote for this exact
4821    /// `(task_id, attempt)` run, independent of `get_latest_by_name`'s
4822    /// cross-Run race (two Runs sharing a producer name never bleed into
4823    /// each other through the Run-scoped accessor).
4824    #[tokio::test]
4825    async fn submit_output_final_is_resolvable_via_run_scoped_lookup() {
4826        let (engine, _op, task_id, worker_token) = seeded_task("reviewer").await;
4827        let data_store: Arc<dyn crate::store::output::OutputStore> =
4828            Arc::new(InMemoryOutputStore::new());
4829        engine.set_output_store(data_store.clone());
4830
4831        engine
4832            .submit_output(
4833                &worker_token,
4834                &task_id,
4835                1,
4836                final_event(serde_json::json!({"verdict": "pass"}), true),
4837            )
4838            .await
4839            .expect("submit_output");
4840
4841        let record = data_store
4842            .get_latest_by_name_in_run(task_id.as_str(), 1, "reviewer")
4843            .await
4844            .expect("run-scoped lookup resolves the dual-written record");
4845        assert!(matches!(record.event, OutputEvent::Final { ok: true, .. }));
4846
4847        // A different attempt of the same task must not resolve — the
4848        // Run-scoped lookup does not fall back across attempts.
4849        assert!(
4850            data_store
4851                .get_latest_by_name_in_run(task_id.as_str(), 2, "reviewer")
4852                .await
4853                .is_err(),
4854            "a different attempt must not resolve the same-named record"
4855        );
4856    }
4857
4858    // ─── staged part file materialize ───
4859
4860    /// Staging a part with a resolved `work_dir` writes
4861    /// `<work_dir>/workspace/tasks/<task_id>/ctx/<name>` with the part's
4862    /// content RAW (no front matter / fenced wrapper).
4863    #[tokio::test]
4864    async fn stage_artifact_materializes_part_file_when_work_dir_resolved() {
4865        let dir = tempfile::TempDir::new().unwrap();
4866        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4867        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4868
4869        engine
4870            .stage_worker_artifact_trusted(
4871                &task_id,
4872                1,
4873                "plan.md".to_string(),
4874                serde_json::json!("# Plan\n\nstep one\n"),
4875            )
4876            .await
4877            .expect("stage artifact");
4878
4879        let expected_file = dir
4880            .path()
4881            .join("workspace/tasks")
4882            .join(task_id.as_str())
4883            .join("ctx/plan.md");
4884        assert!(
4885            expected_file.exists(),
4886            "materialized part file missing at {expected_file:?}"
4887        );
4888        let body = std::fs::read_to_string(expected_file).unwrap();
4889        // Raw — no YAML front matter / fenced-json wrapper.
4890        assert_eq!(body, "# Plan\n\nstep one\n");
4891    }
4892
4893    /// No resolvable root + `Warn` — staging still
4894    /// succeeds (fail-open), and no part file is written.
4895    #[tokio::test]
4896    async fn stage_artifact_check_policy_warn_skips_part_file_when_root_unresolved() {
4897        let dir = tempfile::TempDir::new().unwrap();
4898        let (engine, _op, task_id, _worker_token) =
4899            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Warn).await;
4900        // No seed_agent_context — root unresolved.
4901
4902        let result = engine
4903            .stage_worker_artifact_trusted(
4904                &task_id,
4905                1,
4906                "plan.md".to_string(),
4907                serde_json::json!("x"),
4908            )
4909            .await;
4910        assert!(
4911            result.is_ok(),
4912            "Warn mode preserves fail-open: stage must succeed when root unresolved"
4913        );
4914        assert!(
4915            !dir.path().join("workspace").exists(),
4916            "no part file may be materialized when root is unresolved"
4917        );
4918    }
4919
4920    /// No resolvable root + `Strict` — staging surfaces
4921    /// the fail-open condition as an [`EngineError::CheckPolicyStrict`],
4922    /// its message identifying the "part file materialize" call site.
4923    #[tokio::test]
4924    async fn stage_artifact_check_policy_strict_surfaces_error_when_root_unresolved() {
4925        let (engine, _op, task_id, _worker_token) =
4926            seeded_task_with_policy("planner", crate::core::config::CheckPolicy::Strict).await;
4927
4928        let err = engine
4929            .stage_worker_artifact_trusted(
4930                &task_id,
4931                1,
4932                "plan.md".to_string(),
4933                serde_json::json!("x"),
4934            )
4935            .await
4936            .expect_err("Strict mode must return an error when root unresolved");
4937        match err {
4938            EngineError::CheckPolicyStrict { context, message } => {
4939                assert!(
4940                    context.contains("part file materialize"),
4941                    "context must identify the call site: {context}"
4942                );
4943                assert!(
4944                    message.contains("part file materialize"),
4945                    "message must identify the part-file sink: {message}"
4946                );
4947                assert!(
4948                    message.contains("no work_dir/project_root resolved"),
4949                    "message must preserve the warn-log literal: {message}"
4950                );
4951            }
4952            other => panic!(
4953                "expected EngineError::CheckPolicyStrict, got a different variant: {other:?}"
4954            ),
4955        }
4956    }
4957
4958    /// A path-traversal `name` (`../evil.md`) with a
4959    /// resolved root — the name guard fails the write, but fail-open keeps
4960    /// the stage succeeding, and nothing is written outside the ctx dir.
4961    #[tokio::test]
4962    async fn stage_artifact_traversal_name_is_fail_open_and_writes_nothing() {
4963        let dir = tempfile::TempDir::new().unwrap();
4964        let (engine, _op, task_id, _worker_token) = seeded_task("planner").await;
4965        seed_agent_context(&engine, &task_id, 1, &dir.path().to_string_lossy()).await;
4966
4967        let result = engine
4968            .stage_worker_artifact_trusted(
4969                &task_id,
4970                1,
4971                "../evil.md".to_string(),
4972                serde_json::json!("pwned"),
4973            )
4974            .await;
4975        assert!(
4976            result.is_ok(),
4977            "default (Warn) policy is fail-open even on a rejected part name"
4978        );
4979        // The escaped target (ctx dir's parent) must not have been written.
4980        let escaped = dir
4981            .path()
4982            .join("workspace/tasks")
4983            .join(task_id.as_str())
4984            .join("evil.md");
4985        assert!(
4986            !escaped.exists(),
4987            "a traversal name must never write outside the ctx dir: {escaped:?}"
4988        );
4989    }
4990}
4991
4992/// GH #36 ST1: named multi-part worker output. Covers (a) the pure
4993/// `fold_final_and_parts` assembly `dispatch_attempt_with`'s Final-pull
4994/// delegates to, (b) `stage_worker_artifact_trusted`'s per-attempt
4995/// isolation on `EngineState.output_store` / `.worker_artifact_names` (the
4996/// same `HashMap<(StepId, u32), _>` key shape `submit_worker_result_trusted`
4997/// uses — a fresh attempt is a fresh key, so nothing to explicitly "clean
4998/// up"), and (c) the allowlist behavior that keeps a non-opt-in `Artifact`
4999/// producer (e.g. `AfterRunAuditMiddleware`) from being folded in.
5000#[cfg(test)]
5001mod named_multi_part_worker_output_tests {
5002    use super::*;
5003    use crate::worker::output::{ContentRef, OutputEvent};
5004
5005    fn artifact(name: &str, value: Value) -> OutputEvent {
5006        OutputEvent::Artifact {
5007            name: name.to_string(),
5008            content: ContentRef::Inline { value },
5009        }
5010    }
5011
5012    fn final_ev(value: Value, ok: bool) -> OutputEvent {
5013        OutputEvent::Final {
5014            content: ContentRef::Inline { value },
5015            ok,
5016        }
5017    }
5018
5019    fn names(list: &[&str]) -> Vec<String> {
5020        list.iter().map(|s| s.to_string()).collect()
5021    }
5022
5023    /// Two staged parts (both in `staged_names`) + a `Final` fold into
5024    /// `{"out", "parts"}`, each value carried through verbatim.
5025    #[test]
5026    fn fold_final_and_parts_assembles_out_and_parts_shape() {
5027        let tail = vec![
5028            artifact("summary", serde_json::json!("the summary")),
5029            artifact("diff", serde_json::json!({"lines": 3})),
5030            final_ev(serde_json::json!("final text"), true),
5031        ];
5032        let staged = names(&["summary", "diff"]);
5033        let (value, ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
5034        assert!(ok);
5035        assert_eq!(
5036            value,
5037            serde_json::json!({
5038                "out": "final text",
5039                "parts": {
5040                    "summary": "the summary",
5041                    "diff": {"lines": 3},
5042                }
5043            })
5044        );
5045    }
5046
5047    /// Zero staged parts: the value is exactly the plain `Final` value — no
5048    /// `{"out", "parts"}` wrapping. This is the back-compat guarantee (GH
5049    /// #36 must not change the shape for a worker that never POSTs to
5050    /// `/v1/worker/artifact`).
5051    #[test]
5052    fn fold_final_and_parts_with_no_parts_returns_plain_final_value() {
5053        let tail = vec![final_ev(serde_json::json!("plain value"), true)];
5054        let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
5055        assert!(ok);
5056        assert_eq!(value, serde_json::json!("plain value"));
5057    }
5058
5059    /// The same staged part `name` appearing twice in one attempt: the
5060    /// LATER (tail-order) value wins — `parts` is a `Map`, not an
5061    /// accumulating list.
5062    #[test]
5063    fn fold_final_and_parts_same_name_twice_last_write_wins() {
5064        let tail = vec![
5065            artifact("a", serde_json::json!("first")),
5066            artifact("a", serde_json::json!("second")),
5067            final_ev(serde_json::json!("f"), true),
5068        ];
5069        let staged = names(&["a"]);
5070        let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
5071        assert_eq!(
5072            value,
5073            serde_json::json!({"out": "f", "parts": {"a": "second"}})
5074        );
5075    }
5076
5077    /// No `Final` anywhere in the tail (only staged parts, e.g. the worker
5078    /// crashed before submitting) — `None`, the caller's pre-existing "no
5079    /// Final in output_tail" error path.
5080    #[test]
5081    fn fold_final_and_parts_returns_none_when_no_final_present() {
5082        let tail = vec![artifact("a", serde_json::json!("v"))];
5083        let staged = names(&["a"]);
5084        assert!(fold_final_and_parts(&tail, &staged).is_none());
5085    }
5086
5087    /// An `Artifact` on the tail whose name is NOT in `staged_names` (e.g.
5088    /// `AfterRunAuditMiddleware`'s `"audit:<step_ref>"` sidecar finding on
5089    /// an audited step's own tail) must NOT be folded into `"parts"` — the
5090    /// value stays the plain `Final` value, exactly the pre-GH-#36
5091    /// behavior for every producer that isn't the worker's own
5092    /// `/v1/worker/artifact` staging. This is the regression this fold was
5093    /// almost shipped without (see `dispatch_attempt_with`'s doc).
5094    #[test]
5095    fn fold_final_and_parts_ignores_artifacts_outside_the_staged_allowlist() {
5096        let tail = vec![
5097            final_ev(serde_json::json!({"echoed": "hi"}), true),
5098            artifact("audit:echo", serde_json::json!({"finding": "clean"})),
5099        ];
5100        // `staged_names` empty: the worker itself never staged anything —
5101        // the audit sidecar Artifact must be ignored.
5102        let (value, ok) = fold_final_and_parts(&tail, &[]).expect("Final present");
5103        assert!(ok);
5104        assert_eq!(value, serde_json::json!({"echoed": "hi"}));
5105    }
5106
5107    /// Mixed tail: one staged (allowlisted) part and one non-staged
5108    /// (audit-style) `Artifact` — only the staged one is folded in.
5109    #[test]
5110    fn fold_final_and_parts_folds_only_the_staged_subset_of_a_mixed_tail() {
5111        let tail = vec![
5112            artifact("summary", serde_json::json!("s")),
5113            artifact("audit:echo", serde_json::json!({"finding": "clean"})),
5114            final_ev(serde_json::json!("f"), true),
5115        ];
5116        let staged = names(&["summary"]);
5117        let (value, _ok) = fold_final_and_parts(&tail, &staged).expect("Final present");
5118        assert_eq!(
5119            value,
5120            serde_json::json!({"out": "f", "parts": {"summary": "s"}})
5121        );
5122    }
5123
5124    /// `stage_worker_artifact_trusted` writes onto the `(task_id, attempt)`
5125    /// key exactly like `submit_worker_result_trusted` does — a part staged
5126    /// under attempt N is invisible to an `output_tail` / allowlist read of
5127    /// attempt N+1 (a fresh attempt starts empty; nothing carries over).
5128    #[tokio::test]
5129    async fn stage_worker_artifact_trusted_is_isolated_per_attempt() {
5130        let engine = Engine::new(EngineCfg::default());
5131        let task_id = StepId::new();
5132
5133        engine
5134            .stage_worker_artifact_trusted(&task_id, 1, "a".to_string(), serde_json::json!("v1"))
5135            .await
5136            .expect("stage attempt 1");
5137
5138        let attempt_1_tail = engine.output_tail(&task_id, 1).await;
5139        assert_eq!(attempt_1_tail.len(), 1);
5140        assert!(matches!(
5141            &attempt_1_tail[0],
5142            OutputEvent::Artifact { name, .. } if name == "a"
5143        ));
5144        assert_eq!(
5145            engine.worker_artifact_names_for(&task_id, 1).await,
5146            vec!["a".to_string()]
5147        );
5148
5149        let attempt_2_tail = engine.output_tail(&task_id, 2).await;
5150        assert!(
5151            attempt_2_tail.is_empty(),
5152            "attempt 2 must not see attempt 1's staged part"
5153        );
5154        assert!(
5155            engine
5156                .worker_artifact_names_for(&task_id, 2)
5157                .await
5158                .is_empty(),
5159            "attempt 2's allowlist must not see attempt 1's staged name"
5160        );
5161    }
5162}
5163
5164// ─── GH #50 (Subtask 2): `Engine::register_verdict_contracts` /
5165// `Engine::verdict_contract_for_task` ────────────────────────────────────
5166#[cfg(test)]
5167mod verdict_contract_registry_tests {
5168    use super::*;
5169
5170    async fn seeded_engine(agent: &str) -> (Engine, StepId) {
5171        let engine = Engine::new(EngineCfg::default());
5172        let op_token = engine
5173            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5174            .await
5175            .expect("attach");
5176        let task_id = engine
5177            .start_task(
5178                &op_token,
5179                TaskSpec {
5180                    agent: agent.to_string(),
5181                    initial_directive: serde_json::json!("x"),
5182                    step_ctx: None,
5183                    check_policy: None,
5184                },
5185            )
5186            .await
5187            .expect("start_task");
5188        (engine, task_id)
5189    }
5190
5191    /// An agent with no registered contract at all → `None` (the opt-in
5192    /// default; every pre-GH-#50 `Engine`).
5193    #[tokio::test]
5194    async fn returns_none_when_no_contract_registered_for_the_agent() {
5195        let (engine, task_id) = seeded_engine("gate").await;
5196        assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
5197    }
5198
5199    /// A registered contract for the running task's agent is returned
5200    /// verbatim.
5201    #[tokio::test]
5202    async fn returns_the_registered_contract_for_the_running_agent() {
5203        let (engine, task_id) = seeded_engine("gate").await;
5204        let contract = mlua_swarm_schema::VerdictContract {
5205            channel: mlua_swarm_schema::VerdictChannel::Body,
5206            values: vec!["PASS".to_string(), "BLOCKED".to_string()],
5207        };
5208        engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
5209        assert_eq!(
5210            engine.verdict_contract_for_task(&task_id).await,
5211            Some(contract)
5212        );
5213    }
5214
5215    /// A registered contract for a DIFFERENT agent name never leaks onto
5216    /// an unrelated task.
5217    #[tokio::test]
5218    async fn does_not_leak_a_contract_registered_for_a_different_agent() {
5219        let (engine, task_id) = seeded_engine("gate").await;
5220        engine.register_verdict_contracts(HashMap::from([(
5221            "other-agent".to_string(),
5222            mlua_swarm_schema::VerdictContract {
5223                channel: mlua_swarm_schema::VerdictChannel::Body,
5224                values: vec!["PASS".to_string()],
5225            },
5226        )]));
5227        assert_eq!(engine.verdict_contract_for_task(&task_id).await, None);
5228    }
5229
5230    /// An unknown `task_id` → `None`, not a panic / error.
5231    #[tokio::test]
5232    async fn returns_none_for_an_unknown_task_id() {
5233        let engine = Engine::new(EngineCfg::default());
5234        let unknown = StepId::new();
5235        assert_eq!(engine.verdict_contract_for_task(&unknown).await, None);
5236    }
5237
5238    /// `register_verdict_contracts` is additive (`HashMap::extend`): a
5239    /// second call registering a DIFFERENT agent does not clobber the
5240    /// first call's entry.
5241    #[tokio::test]
5242    async fn register_verdict_contracts_is_additive_across_calls() {
5243        let (engine, task_id) = seeded_engine("gate").await;
5244        let contract = mlua_swarm_schema::VerdictContract {
5245            channel: mlua_swarm_schema::VerdictChannel::Part,
5246            values: vec!["ALLOW".to_string()],
5247        };
5248        engine.register_verdict_contracts(HashMap::from([("gate".to_string(), contract.clone())]));
5249        engine.register_verdict_contracts(HashMap::from([(
5250            "unrelated-agent".to_string(),
5251            mlua_swarm_schema::VerdictContract {
5252                channel: mlua_swarm_schema::VerdictChannel::Body,
5253                values: vec!["X".to_string()],
5254            },
5255        )]));
5256        assert_eq!(
5257            engine.verdict_contract_for_task(&task_id).await,
5258            Some(contract)
5259        );
5260    }
5261}
5262
5263// ─── GH #51: completion-time verdict-contract enforcement — the shared
5264// `Engine::verdict_contract_completion_check` choke point embedded inside
5265// `submit_worker_result_trusted` / `submit_output`, exercised here at the
5266// `submit_output` level (the WS Operator fallback route's own unit-test
5267// coverage — see `crates/mlua-swarm-server/tests/verdict_contract.rs` for
5268// the HTTP-round-trip coverage of the other 2 routes) ───────────────────
5269#[cfg(test)]
5270mod verdict_contract_completion_tests {
5271    use super::*;
5272
5273    /// Seeds a `Pending` task bound to `agent` and mints a bound
5274    /// `Role::Worker` token for it — the same mint-and-register pattern
5275    /// `initial_directive_value_passthrough_tests::mint_worker_token`
5276    /// uses (duplicated here: that helper is private to its own sibling
5277    /// `#[cfg(test)]` module, not reachable via `super::*` from this one).
5278    async fn seeded_task_with_worker_token(agent: &str) -> (Engine, CapToken, StepId) {
5279        let engine = Engine::new(EngineCfg::default());
5280        let op_token = engine
5281            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5282            .await
5283            .expect("attach");
5284        let task_id = engine
5285            .start_task(
5286                &op_token,
5287                TaskSpec {
5288                    agent: agent.to_string(),
5289                    initial_directive: serde_json::json!("x"),
5290                    step_ctx: None,
5291                    check_policy: None,
5292                },
5293            )
5294            .await
5295            .expect("start_task");
5296        let worker_token = engine.signer().session(
5297            format!("worker-of-{task_id}"),
5298            Role::Worker,
5299            vec!["*".into()],
5300            Duration::from_secs(600),
5301        );
5302        let fp = worker_token.fingerprint();
5303        let record = CapTokenRecord::from_worker_token(worker_token.clone(), task_id.clone());
5304        engine
5305            .with_state("test.mint_worker", move |s| {
5306                s.tokens.insert(fp, record);
5307            })
5308            .await
5309            .expect("mint worker token");
5310        (engine, worker_token, task_id)
5311    }
5312
5313    fn body_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
5314        mlua_swarm_schema::VerdictContract {
5315            channel: mlua_swarm_schema::VerdictChannel::Body,
5316            values: values.iter().map(|v| v.to_string()).collect(),
5317        }
5318    }
5319
5320    fn part_contract(values: &[&str]) -> mlua_swarm_schema::VerdictContract {
5321        mlua_swarm_schema::VerdictContract {
5322            channel: mlua_swarm_schema::VerdictChannel::Part,
5323            values: values.iter().map(|v| v.to_string()).collect(),
5324        }
5325    }
5326
5327    fn final_event(value: Value, ok: bool) -> crate::worker::output::OutputEvent {
5328        crate::worker::output::OutputEvent::Final {
5329            content: crate::worker::output::ContentRef::Inline { value },
5330            ok,
5331        }
5332    }
5333
5334    /// Route 3 (WS Operator fallback, `submit_output` level) — a
5335    /// `channel: "part"` contract's attempt completes via a plain
5336    /// `Final` without ever staging a `"verdict"` artifact: rejected
5337    /// with `EngineError::VerdictPartMissing`, and nothing lands on
5338    /// `output_tail` — the rejected value never reaches the flow ctx.
5339    #[tokio::test]
5340    async fn submit_output_rejects_missing_verdict_part() {
5341        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5342        engine.register_verdict_contracts(HashMap::from([(
5343            "gate".to_string(),
5344            part_contract(&["PASS", "BLOCKED"]),
5345        )]));
5346
5347        let err = engine
5348            .submit_output(
5349                &token,
5350                &task_id,
5351                1,
5352                final_event(serde_json::json!("anything"), true),
5353            )
5354            .await
5355            .expect_err("missing staged verdict part must be rejected");
5356        assert!(
5357            matches!(err, EngineError::VerdictPartMissing { .. }),
5358            "unexpected error variant: {err:?}"
5359        );
5360
5361        let tail = engine.output_tail(&task_id, 1).await;
5362        assert!(
5363            !tail
5364                .iter()
5365                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5366            "a rejected completion must not write a Final onto output_tail"
5367        );
5368    }
5369
5370    /// Route 3 — a `channel: "part"` contract completes normally when the
5371    /// worker DID stage a matching `"verdict"` artifact first (defense in
5372    /// depth: presence AND membership both hold).
5373    #[tokio::test]
5374    async fn submit_output_accepts_when_verdict_part_is_staged_and_a_member() {
5375        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5376        engine.register_verdict_contracts(HashMap::from([(
5377            "gate".to_string(),
5378            part_contract(&["PASS", "BLOCKED"]),
5379        )]));
5380        engine
5381            .stage_worker_artifact_trusted(
5382                &task_id,
5383                1,
5384                "verdict".to_string(),
5385                serde_json::json!("PASS"),
5386            )
5387            .await
5388            .expect("stage verdict part");
5389
5390        engine
5391            .submit_output(
5392                &token,
5393                &task_id,
5394                1,
5395                final_event(serde_json::json!("full report"), true),
5396            )
5397            .await
5398            .expect("staged + member verdict part must be accepted");
5399
5400        let tail = engine.output_tail(&task_id, 1).await;
5401        assert!(
5402            tail.iter()
5403                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5404            "an accepted completion must write its Final onto output_tail"
5405        );
5406    }
5407
5408    /// Route 3 — a `channel: "body"` contract's completing value is NOT a
5409    /// member of `values`: rejected with
5410    /// `EngineError::VerdictValueRejected`, no `Final` written.
5411    #[tokio::test]
5412    async fn submit_output_rejects_body_value_outside_contract() {
5413        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5414        engine.register_verdict_contracts(HashMap::from([(
5415            "gate".to_string(),
5416            body_contract(&["PASS", "BLOCKED"]),
5417        )]));
5418
5419        let err = engine
5420            .submit_output(
5421                &token,
5422                &task_id,
5423                1,
5424                final_event(serde_json::json!("UNKNOWN"), true),
5425            )
5426            .await
5427            .expect_err("out-of-contract body value must be rejected");
5428        match err {
5429            EngineError::VerdictValueRejected { value, allowed } => {
5430                assert_eq!(value, "UNKNOWN");
5431                assert_eq!(allowed, vec!["PASS".to_string(), "BLOCKED".to_string()]);
5432            }
5433            other => panic!("unexpected error variant: {other:?}"),
5434        }
5435
5436        let tail = engine.output_tail(&task_id, 1).await;
5437        assert!(
5438            !tail
5439                .iter()
5440                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5441            "a rejected completion must not write a Final onto output_tail"
5442        );
5443    }
5444
5445    /// `ok=false` bypasses the completion-time check entirely, regardless
5446    /// of channel or membership — the exemption acceptance criterion,
5447    /// exercised at the `submit_output` choke point.
5448    #[tokio::test]
5449    async fn submit_output_ok_false_bypasses_the_check() {
5450        let (engine, token, task_id) = seeded_task_with_worker_token("gate").await;
5451        engine.register_verdict_contracts(HashMap::from([(
5452            "gate".to_string(),
5453            body_contract(&["PASS", "BLOCKED"]),
5454        )]));
5455
5456        engine
5457            .submit_output(
5458                &token,
5459                &task_id,
5460                1,
5461                final_event(serde_json::json!("UNKNOWN"), false),
5462            )
5463            .await
5464            .expect("ok=false must bypass the verdict contract check entirely");
5465
5466        let tail = engine.output_tail(&task_id, 1).await;
5467        assert!(
5468            tail.iter()
5469                .any(|ev| matches!(ev, crate::worker::output::OutputEvent::Final { .. })),
5470            "an ok=false completion is exempt, not rejected — its Final must still land"
5471        );
5472    }
5473
5474    /// `staged_verdict_value_for` mirrors `fold_final_and_parts`'s
5475    /// last-write-wins semantics: staging `"verdict"` twice within the
5476    /// same attempt returns the LAST value, not the first.
5477    #[tokio::test]
5478    async fn staged_verdict_value_for_is_last_write_wins() {
5479        let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5480        engine
5481            .stage_worker_artifact_trusted(
5482                &task_id,
5483                1,
5484                "verdict".to_string(),
5485                serde_json::json!("PASS"),
5486            )
5487            .await
5488            .expect("stage first verdict part");
5489        engine
5490            .stage_worker_artifact_trusted(
5491                &task_id,
5492                1,
5493                "verdict".to_string(),
5494                serde_json::json!("BLOCKED"),
5495            )
5496            .await
5497            .expect("stage second verdict part");
5498
5499        assert_eq!(
5500            engine.staged_verdict_value_for(&task_id, 1).await,
5501            Some("BLOCKED".to_string())
5502        );
5503    }
5504
5505    /// `staged_verdict_value_for` ignores artifacts staged under any name
5506    /// OTHER than the literal `"verdict"` — mirrors `channel: "part"`
5507    /// contracts only ever addressing that one part.
5508    #[tokio::test]
5509    async fn staged_verdict_value_for_ignores_other_artifact_names() {
5510        let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5511        engine
5512            .stage_worker_artifact_trusted(
5513                &task_id,
5514                1,
5515                "notes".to_string(),
5516                serde_json::json!("irrelevant"),
5517            )
5518            .await
5519            .expect("stage unrelated part");
5520
5521        assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5522    }
5523
5524    /// `staged_verdict_value_for` → `None` when nothing was ever staged —
5525    /// the normal case the completion check turns into
5526    /// `EngineError::VerdictPartMissing`.
5527    #[tokio::test]
5528    async fn staged_verdict_value_for_returns_none_when_nothing_staged() {
5529        let (engine, _token, task_id) = seeded_task_with_worker_token("gate").await;
5530        assert_eq!(engine.staged_verdict_value_for(&task_id, 1).await, None);
5531    }
5532}
5533
5534// ─── GH #76 Skip tier: DispatchOutcome::Skip tier + SubmitOutcome API ────────────
5535#[cfg(test)]
5536mod skip_tier_tests {
5537    use super::*;
5538    use crate::blueprint::compiler::{RustFnInProcessSpawnerFactory, SpawnerFactory};
5539    use crate::blueprint::EngineDispatcher;
5540    use crate::core::state::{
5541        is_skip_marker, unwrap_skip_marker, wrap_skip_marker, SubmitOutcome, SKIP_MARKER_KEY,
5542    };
5543    use crate::store::run::{InMemoryRunStore, RunContext, RunRecord, RunStatus, RunStore};
5544    use crate::types::{RunId, TaskId};
5545    use crate::worker::adapter::WorkerResult;
5546    use mlua_flow_ir::AsyncDispatcher;
5547    use mlua_swarm_schema::{AgentDef, AgentKind};
5548    use serde_json::json;
5549
5550    /// `DispatchOutcome::Skip(v)` roundtrips through serde JSON without
5551    /// loss — the enum is serialized with the default externally-tagged
5552    /// form (same as `Pass`/`Blocked`), so no `#[serde(...)]` tuning is
5553    /// needed for the new variant.
5554    #[test]
5555    fn dispatch_outcome_skip_variant_serializes_roundtrip() {
5556        let outcome = DispatchOutcome::Skip(json!({ "verdict": "SKIP", "reason": "n/a" }));
5557        let serialized = serde_json::to_string(&outcome).expect("serialize");
5558        let round: DispatchOutcome = serde_json::from_str(&serialized).expect("deserialize");
5559        match round {
5560            DispatchOutcome::Skip(v) => {
5561                assert_eq!(v, json!({ "verdict": "SKIP", "reason": "n/a" }));
5562            }
5563            other => panic!("expected Skip after roundtrip, got {other:?}"),
5564        }
5565    }
5566
5567    /// The `is_skip_marker` / `unwrap_skip_marker` / `wrap_skip_marker`
5568    /// helper triangle round-trips consistently and rejects plain
5569    /// payloads. Pinning the reserved-key contract in a unit test guards
5570    /// against a future edit accidentally renaming the sentinel key
5571    /// (which would silently break every downstream reader).
5572    #[test]
5573    fn skip_marker_helpers_wrap_detect_and_unwrap() {
5574        assert!(!is_skip_marker(&json!("plain string")));
5575        assert!(!is_skip_marker(&json!({ "verdict": "PASS" })));
5576        assert!(!is_skip_marker(&json!(null)));
5577
5578        let inner = json!({ "reason": "not applicable" });
5579        let wrapped = wrap_skip_marker(inner.clone());
5580        assert!(is_skip_marker(&wrapped));
5581        assert_eq!(wrapped[SKIP_MARKER_KEY], json!(true));
5582        assert_eq!(unwrap_skip_marker(&wrapped), Some(inner));
5583
5584        // A malformed sentinel (marker key present but `value` absent) is
5585        // still a Skip signal, defaulting the carried payload to Null so
5586        // downstream match arms never observe `None` on a marker match.
5587        let malformed = json!({ SKIP_MARKER_KEY: true });
5588        assert!(is_skip_marker(&malformed));
5589        assert_eq!(unwrap_skip_marker(&malformed), Some(Value::Null));
5590
5591        // Plain payloads → `unwrap_skip_marker` returns `None` (the
5592        // caller falls back to the ordinary Pass/Blocked path).
5593        assert_eq!(unwrap_skip_marker(&json!("plain")), None);
5594    }
5595
5596    /// The `SubmitOutcome::Skip` mapping wraps the payload in the
5597    /// skip-marker sentinel AND records `Final.ok = true` — matching the
5598    /// invariant in the outcome mapping table in
5599    /// `submit_worker_result_trusted`'s doc. This is the wire shape
5600    /// `dispatch_attempt_with*` reads back to route into
5601    /// `DispatchOutcome::Skip`.
5602    #[tokio::test]
5603    async fn submit_worker_result_trusted_skip_outcome_records_final_ok_true_with_sentinel() {
5604        use crate::worker::output::OutputEvent;
5605        let engine = Engine::new(EngineCfg::default());
5606        let op_token = engine
5607            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5608            .await
5609            .expect("attach");
5610        let task_id = engine
5611            .start_task(
5612                &op_token,
5613                TaskSpec {
5614                    agent: "analyst".into(),
5615                    initial_directive: json!("go"),
5616                    step_ctx: None,
5617                    check_policy: None,
5618                },
5619            )
5620            .await
5621            .expect("start_task");
5622
5623        let inner_verdict = json!({ "verdict": "SKIP", "reason": "migration=no" });
5624        engine
5625            .submit_worker_result_trusted(&task_id, 1, inner_verdict.clone(), SubmitOutcome::Skip)
5626            .await
5627            .expect("submit with Skip outcome");
5628
5629        let tail = engine.output_tail(&task_id, 1).await;
5630        let final_ev = tail
5631            .iter()
5632            .rev()
5633            .find_map(|ev| match ev {
5634                OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5635                _ => None,
5636            })
5637            .expect("Final present after Skip submit");
5638        assert!(
5639            final_ev.1,
5640            "Skip records Final.ok = true (flow-continuation)"
5641        );
5642        let stored_value = super::content_ref_to_value(final_ev.0);
5643        assert!(
5644            is_skip_marker(&stored_value),
5645            "Skip wraps the payload in the sentinel: got {stored_value}"
5646        );
5647        assert_eq!(unwrap_skip_marker(&stored_value), Some(inner_verdict));
5648    }
5649
5650    /// The new `SubmitOutcome::Pass` / `SubmitOutcome::Blocked` arms
5651    /// preserve byte-for-byte the pre-#76 wire shape (Final.ok mirrors
5652    /// the tier; the value is not wrapped). Regression against a future
5653    /// edit that accidentally routes Pass/Blocked through the Skip
5654    /// wrapper.
5655    #[tokio::test]
5656    async fn submit_worker_result_trusted_pass_and_blocked_wire_unchanged() {
5657        use crate::worker::output::OutputEvent;
5658        let engine = Engine::new(EngineCfg::default());
5659        let op_token = engine
5660            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5661            .await
5662            .expect("attach");
5663
5664        // Pass path.
5665        let pass_task = engine
5666            .start_task(
5667                &op_token,
5668                TaskSpec {
5669                    agent: "worker".into(),
5670                    initial_directive: json!("go"),
5671                    step_ctx: None,
5672                    check_policy: None,
5673                },
5674            )
5675            .await
5676            .expect("start_task pass");
5677        engine
5678            .submit_worker_result_trusted(&pass_task, 1, json!("pass-value"), SubmitOutcome::Pass)
5679            .await
5680            .expect("submit Pass");
5681        let pass_tail = engine.output_tail(&pass_task, 1).await;
5682        let (pass_content, pass_ok) = pass_tail
5683            .iter()
5684            .rev()
5685            .find_map(|ev| match ev {
5686                OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5687                _ => None,
5688            })
5689            .expect("Final present");
5690        assert!(pass_ok);
5691        assert_eq!(
5692            super::content_ref_to_value(pass_content),
5693            json!("pass-value"),
5694            "Pass value must not be wrapped"
5695        );
5696
5697        // Blocked path.
5698        let blocked_task = engine
5699            .start_task(
5700                &op_token,
5701                TaskSpec {
5702                    agent: "worker".into(),
5703                    initial_directive: json!("go"),
5704                    step_ctx: None,
5705                    check_policy: None,
5706                },
5707            )
5708            .await
5709            .expect("start_task blocked");
5710        engine
5711            .submit_worker_result_trusted(
5712                &blocked_task,
5713                1,
5714                json!("blocked-value"),
5715                SubmitOutcome::Blocked,
5716            )
5717            .await
5718            .expect("submit Blocked");
5719        let blocked_tail = engine.output_tail(&blocked_task, 1).await;
5720        let (blocked_content, blocked_ok) = blocked_tail
5721            .iter()
5722            .rev()
5723            .find_map(|ev| match ev {
5724                OutputEvent::Final { content, ok } => Some((content.clone(), *ok)),
5725                _ => None,
5726            })
5727            .expect("Final present");
5728        assert!(!blocked_ok);
5729        assert_eq!(
5730            super::content_ref_to_value(blocked_content),
5731            json!("blocked-value"),
5732            "Blocked value must not be wrapped"
5733        );
5734    }
5735
5736    /// End-to-end (engine layer): a worker that returns a skip-marker
5737    /// sentinel value via `WorkerResult { value: wrap_skip_marker(inner),
5738    /// ok: true }` — which is what a Skip-aware caller of
5739    /// `submit_worker_result_trusted(..., SubmitOutcome::Skip)` places on
5740    /// the wire — is folded by `dispatch_attempt_with_run_ctx` into
5741    /// `DispatchOutcome::Skip(inner)`. Proves the sentinel → outcome
5742    /// routing that the flow-ir binding boundary depends on.
5743    #[tokio::test]
5744    async fn dispatcher_folds_skip_sentinel_into_skip_outcome() {
5745        let inner_verdict = json!({ "verdict": "SKIP", "reason": "not applicable" });
5746        let inner_for_worker = inner_verdict.clone();
5747        let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5748            let value = wrap_skip_marker(inner_for_worker.clone());
5749            async move {
5750                Ok(WorkerResult {
5751                    value,
5752                    ok: true,
5753                    stats: None,
5754                })
5755            }
5756        });
5757        let def = AgentDef {
5758            name: "analyst".into(),
5759            kind: AgentKind::RustFn,
5760            spec: json!({ "fn_id": "analyst" }),
5761            profile: None,
5762            meta: None,
5763            runner: None,
5764            runner_ref: None,
5765            verdict: None,
5766        };
5767        let spawner = factory.build(&def, None).expect("build");
5768
5769        let engine = Engine::new(EngineCfg::default());
5770        let op_token = engine
5771            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5772            .await
5773            .expect("attach");
5774        let task_id = engine
5775            .start_task(
5776                &op_token,
5777                TaskSpec {
5778                    agent: "analyst".into(),
5779                    initial_directive: json!("go"),
5780                    step_ctx: None,
5781                    check_policy: None,
5782                },
5783            )
5784            .await
5785            .expect("start_task");
5786
5787        let outcome = engine
5788            .dispatch_attempt_with_run_ctx(&op_token, &task_id, &spawner, None)
5789            .await
5790            .expect("dispatch ok");
5791
5792        match outcome {
5793            DispatchOutcome::Skip(v) => {
5794                assert_eq!(v, inner_verdict, "Skip carries the unwrapped inner verdict");
5795            }
5796            other => panic!("expected DispatchOutcome::Skip, got {other:?}"),
5797        }
5798    }
5799
5800    /// `EngineDispatcher::dispatch` (the `AsyncDispatcher` impl flow-ir
5801    /// invokes) maps `DispatchOutcome::Skip(v)` to `Ok(wrap_skip_marker(v))`
5802    /// — a successful return whose Value carries the sentinel across the
5803    /// flow-ir boundary. Pinning this mapping in a test guards the arm
5804    /// order (a wildcard `Ok(other) =>` arm accidentally placed BEFORE the
5805    /// Skip arm would route Skip to `EvalError::DispatcherError` and
5806    /// abort the flow — the exact failure mode this tier prevents).
5807    #[tokio::test]
5808    async fn engine_dispatcher_maps_skip_outcome_to_ok_sentinel_value() {
5809        let inner_verdict = json!({ "verdict": "SKIP", "reason": "not applicable" });
5810        let inner_for_worker = inner_verdict.clone();
5811        let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5812            let value = wrap_skip_marker(inner_for_worker.clone());
5813            async move {
5814                Ok(WorkerResult {
5815                    value,
5816                    ok: true,
5817                    stats: None,
5818                })
5819            }
5820        });
5821        let def = AgentDef {
5822            name: "analyst".into(),
5823            kind: AgentKind::RustFn,
5824            spec: json!({ "fn_id": "analyst" }),
5825            profile: None,
5826            meta: None,
5827            runner: None,
5828            runner_ref: None,
5829            verdict: None,
5830        };
5831        let spawner = factory.build(&def, None).expect("build");
5832
5833        let engine = Engine::new(EngineCfg::default());
5834        let op_token = engine
5835            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5836            .await
5837            .expect("attach");
5838        let dispatcher = EngineDispatcher::with_spawner(engine.clone(), op_token, spawner);
5839
5840        let out = dispatcher
5841            .dispatch("analyst", json!("go"))
5842            .await
5843            .expect("dispatch returns Ok for Skip tier (not EvalError::DispatcherError)");
5844
5845        assert!(
5846            is_skip_marker(&out),
5847            "returned value must carry the skip-marker sentinel across the flow-ir boundary: got {out}"
5848        );
5849        assert_eq!(unwrap_skip_marker(&out), Some(inner_verdict));
5850    }
5851
5852    /// `EngineDispatcher::dispatch`'s `RunContext` step-entry log records
5853    /// `status = "skipped"` for a Skip completion (distinct from
5854    /// `"passed"` / `"blocked"`), so post-run inspection of
5855    /// `RunRecord.step_entries` can distinguish flow-continuation-with-
5856    /// binding-write from flow-continuation-without-binding-write.
5857    #[tokio::test]
5858    async fn engine_dispatcher_step_entry_status_is_skipped_for_skip_outcome() {
5859        let inner_verdict = json!({ "verdict": "SKIP" });
5860        let inner_for_worker = inner_verdict.clone();
5861        let factory = RustFnInProcessSpawnerFactory::new().register_fn("analyst", move |_inv| {
5862            let value = wrap_skip_marker(inner_for_worker.clone());
5863            async move {
5864                Ok(WorkerResult {
5865                    value,
5866                    ok: true,
5867                    stats: None,
5868                })
5869            }
5870        });
5871        let def = AgentDef {
5872            name: "analyst".into(),
5873            kind: AgentKind::RustFn,
5874            spec: json!({ "fn_id": "analyst" }),
5875            profile: None,
5876            meta: None,
5877            runner: None,
5878            runner_ref: None,
5879            verdict: None,
5880        };
5881        let spawner = factory.build(&def, None).expect("build");
5882
5883        let engine = Engine::new(EngineCfg::default());
5884        let op_token = engine
5885            .attach("ut-op", Role::Operator, Duration::from_secs(30))
5886            .await
5887            .expect("attach");
5888
5889        // Seed a RunContext with an InMemoryRunStore so the dispatcher
5890        // appends a step_entry we can then read back.
5891        let run_id = RunId::new();
5892        let run_store: Arc<dyn RunStore> = Arc::new(InMemoryRunStore::new());
5893        run_store
5894            .create(RunRecord {
5895                id: run_id.clone(),
5896                task_id: TaskId::new(),
5897                status: RunStatus::Running,
5898                step_entries: Vec::new(),
5899                degradations: Vec::new(),
5900                operator_sid: None,
5901                result_ref: None,
5902                input_json: None,
5903                created_at: 0,
5904                updated_at: 0,
5905            })
5906            .await
5907            .expect("create run record");
5908        let run_ctx = RunContext::new(run_id.clone(), run_store.clone());
5909
5910        let dispatcher =
5911            EngineDispatcher::with_spawner(engine.clone(), op_token, spawner).with_run(run_ctx);
5912
5913        let out = dispatcher
5914            .dispatch("analyst", json!("go"))
5915            .await
5916            .expect("dispatch ok");
5917        assert!(is_skip_marker(&out));
5918
5919        let record = run_store.get(&run_id).await.expect("run record present");
5920        let step = record
5921            .step_entries
5922            .first()
5923            .expect("at least one step_entry appended for the dispatched step");
5924        assert_eq!(
5925            step.status.as_deref(),
5926            Some("skipped"),
5927            "Skip outcome must record StepEntry.status = \"skipped\""
5928        );
5929        assert_eq!(step.step_ref.as_deref(), Some("analyst"));
5930    }
5931}