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